/**
* Global Sensor Networks (GSN) Source Code
* Copyright (c) 2006-2016, Ecole Polytechnique Federale de Lausanne (EPFL)
* 
* This file is part of GSN.
* 
* GSN is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* 
* GSN is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
* GNU General Public License for more details.
* 
* You should have received a copy of the GNU General Public License
* along with GSN.  If not, see <http://www.gnu.org/licenses/>.
* 
* File: src/ch/epfl/gsn/beans/StreamElement.java
*
* @author rhietala
* @author Timotee Maret
* @author Sofiane Sarni
* @author Ali Salehi
* @author Mehdi Riahi
* @author Julien Eberle
*
*/

package ch.epfl.gsn.beans;

import play.libs.Json;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.TreeMap;

import org.apache.commons.codec.binary.Base64;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.databind.JsonNode;

import ch.epfl.gsn.beans.json.*;
import ch.epfl.gsn.beans.DataField;
import ch.epfl.gsn.beans.DataTypes;
import ch.epfl.gsn.beans.StreamElement;
import ch.epfl.gsn.delivery.StreamElement4Rest;
import ch.epfl.gsn.utils.CaseInsensitiveComparator;

import org.slf4j.Logger;

public final class StreamElement implements Serializable {

	private static final long                      serialVersionUID  = 2000261462783698617L;

	private static final transient Logger          logger            = LoggerFactory.getLogger( StreamElement.class );

	private transient TreeMap < String , Integer > indexedFieldNames = null;

	private long                                   timeStamp         = -1;

	private String [ ]                             fieldNames;

	private Serializable [ ]                       fieldValues;

	private Byte [ ]                               fieldTypes;

	private transient long                                   internalPrimayKey = -1;

	private static final String NULL_ENCODING = "NULL"; // null encoding for transmission over xml-rpc

	private boolean timestampProvided = false;


	public StreamElement (StreamElement other) {
		this.fieldNames=new String[other.fieldNames.length];
		this.fieldValues=new Serializable[other.fieldValues.length];
		this.fieldTypes=new Byte[other.fieldTypes.length];
		for (int i=0;i<other.fieldNames.length;i++) {
			fieldNames[i]=other.fieldNames[i];
			fieldValues[i]=other.fieldValues[i];
			fieldTypes[i]=other.fieldTypes[i];
		}
		this.timeStamp=other.timeStamp;
		this.internalPrimayKey = other.internalPrimayKey;
	}
	
	public StreamElement(){ //constructor for serialization
		
	}
	public StreamElement ( DataField [ ] outputStructure , final Serializable [ ] data  ) {
		this(outputStructure,data,System.currentTimeMillis());
	}
	public StreamElement ( DataField [ ] outputStructure , final Serializable [ ] data , final long timeStamp ) {
		this.fieldNames = new String [ outputStructure.length ];
		this.fieldTypes = new Byte [ outputStructure.length ];
		this.timeStamp = timeStamp;
		for ( int i = 0 ; i < this.fieldNames.length ; i++ ) {
			this.fieldNames[ i ] = outputStructure[ i ].getName( ).toLowerCase( );
			this.fieldTypes[ i ] = outputStructure[ i ].getDataTypeID( );
		}
		if ( this.fieldNames.length != data.length ) throw new IllegalArgumentException( "The length of dataFileNames and the actual data provided in the constructor of StreamElement doesn't match." );
		this.verifyTypesCompatibility( this.fieldTypes , data );
		this.fieldValues = data;
	}

	public StreamElement ( final String [ ] dataFieldNames , final Byte [ ] dataFieldTypes , final Serializable [ ] data ) {
		this( dataFieldNames , dataFieldTypes , data , System.currentTimeMillis( ) );
	}

	public StreamElement ( final String [ ] dataFieldNames , final Byte [ ] dataFieldTypes , final Serializable [ ] data , final long timeStamp ) {
		if ( dataFieldNames.length != dataFieldTypes.length )
			throw new IllegalArgumentException( "The length of dataFileNames and dataFileTypes provided in the constructor of StreamElement doesn't match." );
		if ( dataFieldNames.length != data.length ) throw new IllegalArgumentException( "The length of dataFileNames and the actual data provided in the constructor of StreamElement doesn't match." );
		this.timeStamp = timeStamp;
		this.timestampProvided=true;
		this.fieldTypes = dataFieldTypes;
		this.fieldNames = dataFieldNames;
		this.fieldValues = data;
		this.verifyTypesCompatibility( dataFieldTypes , data );
	}

	public StreamElement(TreeMap<String, Serializable> output,DataField[] fields) {
		int nbFields = output.keySet().size();
		if(output.containsKey("timed"))
			nbFields--;
		String fieldNames[]  = new String[nbFields];
		Byte fieldTypes[]  = new Byte[nbFields];
		Serializable fieldValues[] = new Serializable[nbFields];
		TreeMap < String , Integer > indexedFieldNames = new TreeMap<String, Integer>(new CaseInsensitiveComparator());
		int idx = 0;

		long timestamp =System.currentTimeMillis();
		for (String key:output.keySet()) {
			Serializable value = output.get(key);

			if(key.equalsIgnoreCase("timed")){
				timestamp = (Long) value;
				timestampProvided=true;
			}else{ 
				fieldNames[idx] = key;
				fieldValues[idx] = value;
				for (int i=0;i<fields.length;i++) {
					if (fields[i].getName().equalsIgnoreCase(key))
						fieldTypes[idx] = fields[i].getDataTypeID();
				}
				indexedFieldNames.put(key, idx);
				idx++;
			}
		}
		this.fieldNames=fieldNames;
		this.fieldTypes=fieldTypes;
		this.fieldValues=fieldValues;
		this.indexedFieldNames=indexedFieldNames;
		this.timeStamp=timestamp;
	}
	
	/**
	 * Verify if the data corresponds to the fieldType
	 * @param fieldType
	 * @param data
	 * @throws IllegalArgumentException
	 */
	private void verifyTypeCompatibility ( Byte fieldType , Serializable data) throws IllegalArgumentException {
			if ( data == null ) return;
			switch ( fieldType ) {
			case DataTypes.TINYINT :
				if ( !( data instanceof Byte ) )
					throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ]
					                                    + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" );
				break;
			case DataTypes.SMALLINT :
				if ( !( data instanceof Short ) )
					throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ]
					                                    + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" );
				break;
			case DataTypes.BIGINT :
				if ( !( data instanceof Long ) ) 
					throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ] 
							                            + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" ); 
				break;
			case DataTypes.CHAR :
			case DataTypes.VARCHAR :
				if ( !( data instanceof String ) ) 
                    throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ] 
                    		                            + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" );
				break;
			case DataTypes.INTEGER :
				if ( !( data instanceof Integer)) 
                    throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ] 
                    		                            + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" ); 
				break;
			case DataTypes.DOUBLE :
				if ( !( data instanceof Double || data instanceof Float ) )
					throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ]
	                                                    + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" );
				break;
			case DataTypes.FLOAT :
				if ( !( data instanceof Float ) )
					throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ]
	                                                    + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" );
				break;
			case DataTypes.BINARY :
				// if ( data[ i ] instanceof String ) data[ i ] = ( ( String )
				// data[ i ] ).getBytes( );
				if ( !( data instanceof byte [ ] || data instanceof String ) )
					throw new IllegalArgumentException( "The field is defined as " + DataTypes.TYPE_NAMES[ fieldType ]
                                                        + " while the actual data in the field is of type : *" + data.getClass( ).getCanonicalName( ) + "*" );
				break;
			}
		}
	
	/**
	 * Checks the type compatibility of all fields of the StreamElement
	 * @param fieldTypes the array of all fields' type
	 * @param data the array of data to check
	 * @throws IllegalArgumentException if a data field doesn't match the given type
	 */
	private void verifyTypesCompatibility ( final Byte [ ] fieldTypes , final Serializable [ ] data ) throws IllegalArgumentException {
		for ( int i = 0 ; i < data.length ; i++ ) {
			try{
				verifyTypeCompatibility(fieldTypes[i], data[i]);
			}catch(IllegalArgumentException e){
				throw new IllegalArgumentException("The newly constructed Stream Element is not consistent for the " + ( i + 1 ) + "th field.", e);
			}
		}
	}

	public String toString ( ) {
		final StringBuffer output = new StringBuffer( "timed = " );
		output.append( this.getTimeStamp( ) ).append( "\t" );
		for ( int i = 0 ; i < this.fieldNames.length ; i++ )
			output.append( "," ).append( this.fieldNames[ i ] ).append( "/" ).append( this.fieldTypes[ i ] ).append( " = " ).append( this.fieldValues[ i ] );
		return output.toString( );
	}

	public final String [ ] getFieldNames ( ) {
		return this.fieldNames;
	}

	/*
	 * Returns the field types in GSN format. Checkout ch.epfl.gsn.beans.DataTypes
	 */
	public final Byte [ ] getFieldTypes ( ) {
		return this.fieldTypes;
	}

	public final Serializable [ ] getData ( ) {
		return this.fieldValues;
	}

	public void setData (int index,Serializable data ) {
		this.fieldValues[index]=data;
	}

	public long getTimeStamp ( ) {
		return this.timeStamp;
	}

	public StringBuilder getFieldTypesInString ( ) {
		final StringBuilder stringBuilder = new StringBuilder( );
		for ( final byte i : this.getFieldTypes( ) )
			stringBuilder.append( DataTypes.TYPE_NAMES[ i ] ).append( " , " );
		return stringBuilder;
	}

	/**
	 * Returns true if the timestamp is set. A timestamp is valid if it is
	 * set.
	 * 
	 * @return Whether the timestamp is set or not. If it is >0 it is assumed to be set
	 */
	public boolean isTimestampSet ( ) {
		return this.timeStamp > 0 || timestampProvided;
	}

	/**
	 * Sets the time stamp of this stream element.
	 * 
	 * @param timeStamp The time stamp value. If the timestamp is zero or
	 * negative, it is considered non valid and zero will be placed.
	 */
	public void setTimeStamp ( long timeStamp ) {
		if ( timeStamp <= 0 )
			timeStamp = 0;
		else
			this.timeStamp = timeStamp;
	}

	/**
	 * This method gets the attribute name as the input and returns the value
	 * corresponding to that tuple.
	 * 
	 * @param fieldName The name of the tuple.
	 * @return The value corresponding to the named tuple.
	 */
	public final Serializable getData ( final String fieldName ) {
		generateIndex();
		Integer index = indexedFieldNames.get( fieldName );
		if (index == null) {
			logger.warn("There is a request for field "+fieldName+" for StreamElement: "+this.toString()+". As the requested field doesn't exist, GSN returns Null to the callee.");
			return null;
		}
		return this.fieldValues[ index ];
	}
	
	/**
	 * This method gets the attribute name as the input and returns the type of the value
	 * corresponding to that tuple.
	 * 
	 * @param fieldName The name of the tuple.
	 * @return The type of the value corresponding to the named tuple.
	 */
	public final Byte getType ( final String fieldName ) {
		generateIndex();
		Integer index = indexedFieldNames.get( fieldName );
		if (index == null) {
			logger.warn("There is a request for type of field "+fieldName+" for StreamElement: "+this.toString()+". As the requested field doesn't exist, GSN returns Null to the callee.");
			return null;
		}
		return this.fieldTypes[ index ];
	}

	public long getInternalPrimayKey ( ) {
		return internalPrimayKey;
	}

	public void setInternalPrimayKey ( long internalPrimayKey ) {
		this.internalPrimayKey = internalPrimayKey;
	}

	/**
	 * @return
	 */
	public Object [ ] getDataInRPCFriendly ( ) {
		Object [ ] toReturn = new Object [ fieldValues.length ];
		for ( int i = 0 ; i < toReturn.length ; i++ ) {
			//process null values
			if (fieldValues[i]==null) {
				toReturn[i] = NULL_ENCODING;
				continue;
			}
			switch ( fieldTypes[ i ] ) {
			case DataTypes.DOUBLE :
			case DataTypes.FLOAT :
				toReturn[ i ] = fieldValues[ i ];
				break;
			case DataTypes.BIGINT :
				toReturn[ i ] = Long.toString( ( Long ) fieldValues[ i ] );
				break;
				//        case DataTypes.TIME :
				//        toReturn[ i ] = Long.toString( ( Long ) fieldValues[ i ] );
				//        break;
			case DataTypes.TINYINT :
			case DataTypes.SMALLINT :
			case DataTypes.INTEGER :
				toReturn[ i ] = new Integer( ( Integer ) fieldValues[ i ] );
				break;
			case DataTypes.CHAR :
			case DataTypes.VARCHAR :
			case DataTypes.BINARY :
				toReturn[ i ] = fieldValues[ i ];
				break;
			default :
				logger.error( "Type can't be converted : TypeID : " + fieldTypes[ i ] );
			}
		}
		return toReturn;

	}
	
	/**
	 * Build stream elements from a JSON representation like this one:
	 * {"type":"Feature","properties":{"vs_name":"geo_oso3m","values":[[1464094800000,21,455.364922]],"fields":[{"name":"timestamp","type":"time","unit":"ms"},{"name":"station","type":"smallint","unit":null},{"name":"altitude","type":"float","unit":null}],"stats":{"start-datetime":1381953249010,"end-datetime":1464096133100},"geographical":"Lausanne, Switzerland","description":"OZ47 Sensor"},"geometry":{"type":"Point","coordinates":[6.565356337141691,46.5608445136986,689.7967]},"total_size":0,"page_size":0}
	 * Expecting the first value to be the timestamp
	 * @param s
	 * @return
	 */
	
	public static StreamElement[] fromJSON(String s){
		JsonNode jn = Json.parse(s).get("properties");
		DataField[] df = new DataField[jn.get("fields").size()-1];
		int i = 0;
		for(JsonNode f : jn.get("fields")){
			if (f.get("name").asText().equals("timestamp")) continue; 
			df[i] = new DataField(f.get("name").asText(),f.get("type").asText());
			i++;
		}
		StreamElement[] ret = new StreamElement[jn.get("values").size()];
		int k = 0;
		for(JsonNode v : jn.get("values")){
			Serializable[] data = new Serializable[df.length];
			for(int j=1;j < v.size();j++){
				switch(df[j-1].getDataTypeID()){
				case DataTypes.DOUBLE:
					data[j-1] = v.get(j).asDouble();
					break;
				case DataTypes.FLOAT:
					data[j-1] = (float)v.get(j).asDouble();
					break;
				case DataTypes.BIGINT:
					data[j-1] = v.get(j).asLong();
					break;
				case DataTypes.TINYINT:
					data[j-1] = (byte)v.get(j).asInt();
					break;
				case DataTypes.SMALLINT:
				case DataTypes.INTEGER:
					data[j-1] = v.get(j).asInt();
					break;
				case DataTypes.CHAR:
				case DataTypes.VARCHAR:
					data[j-1] = v.get(j).asText();
					break;
				case DataTypes.BINARY:
					data[j-1] = (byte[])Base64.decodeBase64(v.get(j).asText());
					break;
				default:
					logger.error("The data type of the field cannot be parsed: " + df[j-1].toString());
				}
			}
			ret[k] = new StreamElement(df, data, v.get(0).asLong());
			k++;
		}
		return ret;
	}
	
	

	
	/**
	 * Returns the type of the field in the output format or -1 if the field doesn't exit.
	 * @param outputFormat
	 * @param fieldName
	 * @return
	 */
	private static byte findIndexInDataField(DataField[] outputFormat, String fieldName) {
		for (int i=0;i<outputFormat.length;i++) 
			if (outputFormat[i].getName( ).equalsIgnoreCase( fieldName ))
				return outputFormat[i].getDataTypeID( );

		return -1;
	}
	/***
	 * Used with the new JRuby/Mongrel/Rest interface
	 */

	public static StreamElement fromREST ( DataField [ ] outputFormat , String [ ] fieldNames , String [ ] fieldValues ,  String timestamp ) {
		Serializable [ ] values = new Serializable [ outputFormat.length ];
		for ( int i = 0 ; i < fieldNames.length ; i++ ) {
			switch ( findIndexInDataField( outputFormat , (String)fieldNames[i] ) ) {
			case DataTypes.DOUBLE :
				values[ i ] = Double.parseDouble(fieldValues[ i ]);
				break;
			case DataTypes.FLOAT :
				values[ i ] = Float.parseFloat( ( String ) fieldValues[ i ] );
				break;
			case DataTypes.BIGINT :
				//        case DataTypes.TIME :
				values[ i ] = Long.parseLong( ( String ) fieldValues[ i ] );
				break;
			case DataTypes.TINYINT :
				values[ i ] = Byte.parseByte( ( String ) fieldValues[ i ] );
				break;
			case DataTypes.SMALLINT :
			case DataTypes.INTEGER :
				values[ i ] = Integer.parseInt( fieldValues[ i ] );
				break;
			case DataTypes.CHAR :
			case DataTypes.VARCHAR :
				values[ i ] = new String( Base64.decodeBase64( fieldValues[ i ].getBytes()));
				break;
			case DataTypes.BINARY :
				values[ i ] = (byte[])  Base64.decodeBase64( fieldValues[ i ].getBytes());
				break;
			case -1:
			default :
				logger.error( "The field name doesn't exit in the output structure : FieldName : "+(String)fieldNames[i]   );
			}
		}
		return new StreamElement( outputFormat , values , Long.parseLong(timestamp ));
	}
	
	public static StreamElement createElementFromREST( DataField [ ] outputFormat , String [ ] fieldNames , Object[ ] fieldValues  ) {
		ArrayList<Serializable> values = new ArrayList<Serializable>();
		// ArrayList<String> fields = new ArrayList<String>();

		long timestamp = -1;
		for ( int i = 0 ; i < fieldNames.length ; i++ ) {
			if (fieldNames[i].equalsIgnoreCase("TIMED")) {
				timestamp = Long.parseLong((String) fieldValues[i]);
				continue;
			}
			boolean found = false;
			for (DataField f:outputFormat) {
				if(f.getName().equalsIgnoreCase(fieldNames[i])) {
					//     fields.add(fieldNames[i]);
					found=true;
					break;
				}
			}
			if (found==false)
				continue;

			switch ( findIndexInDataField( outputFormat ,fieldNames[i] ) ) {
			case DataTypes.DOUBLE :
				values.add(Double.parseDouble( (String)fieldValues[ i ]));
				break;
			case DataTypes.FLOAT :
				values.add(Float.parseFloat( (String)fieldValues[ i ]));
				break;
			case DataTypes.BIGINT :
				values.add( Long.parseLong( (String)  fieldValues[ i ] ));
				break;
			case DataTypes.TINYINT :
				values.add(Byte.parseByte( (String)fieldValues[ i ]));
				break;
			case DataTypes.SMALLINT :
				values.add( Short.parseShort(  (String)fieldValues[ i ] ));
				break;
			case DataTypes.INTEGER :
				values.add( Integer.parseInt(  (String)fieldValues[ i ] ));
				break;
			case DataTypes.CHAR :
			case DataTypes.VARCHAR :
				values.add(new String((byte[]) fieldValues[ i ]));
				break;
			case DataTypes.BINARY :
				try{ 
					//          StreamElementTest.md5Digest(fieldValues[ i ]);
				}catch (Exception e) {
					logger.error(e.getMessage(), e);
				}
				values.add((byte[]) fieldValues[ i ]);
				break;
			case -1:
			default :
				logger.error( "The field name doesn't exit in the output structure : FieldName : "+(String)fieldNames[i]   );
			}

		}
		if (timestamp==-1)
			timestamp=System.currentTimeMillis();
		return new StreamElement( outputFormat , values.toArray(new Serializable[] {}) , timestamp );
	}
	public StreamElement4Rest toRest() {
		StreamElement4Rest toReturn = new StreamElement4Rest(this);
		return toReturn;
	}
	
	/**
	 * Build the index for mapping field name to their positions in the array if it is not yet built
	 * This assumes that StreamElements cannot change their structure
	 */
	private void generateIndex(){
		if ( indexedFieldNames == null ) {
			indexedFieldNames = new TreeMap < String , Integer >( new CaseInsensitiveComparator( ) );
			for ( int i = 0 ; i < this.fieldNames.length ; i++ )
				this.indexedFieldNames.put( fieldNames[ i ] , i );
		}
	}
	
	/**
	 * set the data in the corresponding field, throws an exception if the data type doesn't match
	 * @param fieldName
	 * @param data
	 * @throws IllegalArgumentException
	 */
	public void setData(String fieldName, Serializable data) throws IllegalArgumentException {
		generateIndex();
		Integer index = indexedFieldNames.get( fieldName );
		if (index == null) {
			logger.warn("There is a request for setting field "+fieldName+" for StreamElement: "+this.toString()+". But the requested field doesn't exist.");
		}
		verifyTypeCompatibility(fieldTypes[index], data);
		setData(index,data);		
	}

	public String toJSON(String vs_name){

		GeoJsonField[] fields = new GeoJsonField[getFieldNames().length+1];
		fields[0] = new GeoJsonField();
		fields[0].setName("timestamp");
		fields[0].setType("time");
		fields[0].setUnit("ms");
		for(int i = 1; i < fields.length; i++){
			fields[i] = new GeoJsonField();
			fields[i].setName(getFieldNames()[i-1]);
			fields[i].setType(DataTypes.TYPE_NAMES[getFieldTypes()[i-1]]);
		}
		Serializable[] values = new Serializable[fields.length];
		values[0] = getTimeStamp();
		for(int j = 1; j < fields.length; j++){
			values[j] = fieldValues[j-1];
		}
		GeoJsonProperties prop = new GeoJsonProperties();
		prop.setVs_name(vs_name);
		prop.setFields(fields);
		prop.setValues(new Serializable[][] {values});
        GeoJsonFeature feature = new GeoJsonFeature();
        feature.setPage_size(1);
        feature.setTotal_size(1);
		feature.setType("Feature");
		feature.setProperties(prop);

		return Json.toJson(feature).toString();
    }
}