/*
 *
 *   Licensed under the Apache License, Version 2.0 (the "License");
 *   you may not use this file except in compliance with the License.
 *   You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *   Unless required by applicable law or agreed to in writing, software
 *   distributed under the License is distributed on an "AS IS" BASIS,
 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *   See the License for the specific language governing permissions and
 *   limitations under the License.
 */

package com.github.adejanovski.cassandra.jdbc;

import static com.github.adejanovski.cassandra.jdbc.Utils.NO_RESULTSET;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.URL;
import java.nio.ByteBuffer;
import java.sql.Blob;
import java.sql.Date;
import java.sql.ParameterMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.RowId;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.sql.SQLNonTransientException;
import java.sql.SQLRecoverableException;
import java.sql.SQLTransientException;
import java.sql.Time;
import java.sql.Timestamp;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.datastax.driver.core.BoundStatement;
import com.datastax.driver.core.LocalDate;
import com.datastax.driver.core.ResultSetFuture;
import com.datastax.driver.core.exceptions.CodecNotFoundException;
import com.datastax.driver.core.exceptions.InvalidTypeException;
import com.google.common.collect.Lists;
import com.google.common.io.CharSource;
import com.google.common.io.CharStreams;

class CassandraPreparedStatement extends CassandraStatement implements PreparedStatement
{
    private static final Logger LOG = LoggerFactory.getLogger(CassandraPreparedStatement.class);

    /** the count of bound variable markers (?) encountered in the parse o the CQL server-side */
    private int count;

    /** a Map of the current bound values encountered in setXXX methods */
    private Map<Integer, Object> bindValues = new LinkedHashMap<Integer, Object>();
    private com.datastax.driver.core.PreparedStatement stmt ;
    
    private BoundStatement statement;
    private ArrayList<BoundStatement> batchStatements;
    //private BoundStatement boundStatement;
    //private CassandraResultSet currentResultSet=null;
    protected ResultSet currentResultSet = null;


    CassandraPreparedStatement(CassandraConnection con, String cql) throws SQLException
    {    	
        this(con, cql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY, ResultSet.HOLD_CURSORS_OVER_COMMIT);
    }
    
    
    CassandraPreparedStatement(CassandraConnection con,
        String cql,
        int rsType,
        int rsConcurrency,
        int rsHoldability
        ) throws SQLException
    {
       super(con,cql,rsType,rsConcurrency,rsHoldability);       
       
       if (LOG.isTraceEnabled()) LOG.trace("CQL: " + this.cql);
       try
       {
    	   stmt = this.connection.getSession().prepare(cql); 
    	   this.statement = new BoundStatement(stmt);    	   
    	   batchStatements = Lists.newArrayList();
    	   count = cql.length() - cql.replace("?", "").length();   	   
       }
       catch (Exception e)
       {
           throw new SQLTransientException(e);
       }
    }
    
    String getCql()
    {
        return cql;
    }

    @SuppressWarnings("boxing")
	private final void checkIndex(int index) throws SQLException
    {
        if (index > count) throw new SQLRecoverableException(String.format("the column index : %d is greater than the count of bound variable markers in the CQL: %d",
            index,
            count));
        if (index < 1) throw new SQLRecoverableException(String.format("the column index must be a positive number : %d", index));
    }

    /* private List<ByteBuffer> getBindValues() throws SQLException
    {
    	this.statement.b
        List<ByteBuffer> values = new ArrayList<ByteBuffer>();
        if (bindValues.size() != count) throw new SQLRecoverableException(
            String.format("the number of bound variables: %d must match the count of bound variable markers in the CQL: %d",
            bindValues.size(),
            count));

        for (int i = 1; i <= count; i++)
        {
            ByteBuffer value = bindValues.get(i);
            if (value == null) throw new SQLRecoverableException(String.format("the bound value for index: %d was not set", i));
            values.add(value);
        }
        return values;
    }
*/

    public void close()
    {
        try{
        	connection.removeStatement(this);
        }catch(Exception e){
        	
        }

        //connection = null;
    }
        

    private void doExecute() throws SQLException
    {
        if (LOG.isTraceEnabled()) LOG.trace("CQL: " + cql);
        try
        {
            resetResults();
            if (this.connection.debugMode) System.out.println("CQL: "+ cql);     
            if(this.statement.getFetchSize()==0)
            		// force paging to avoid timeout and node harm...
            		this.statement.setFetchSize(100);
            this.statement.setConsistencyLevel(this.connection.defaultConsistencyLevel);
            for(int i=0; i<this.statement.preparedStatement().getVariables().size(); i++){
            	// Set parameters to null if unset
            	if(!this.statement.isSet(i)){
            		this.statement.setToNull(i);
            	}
            }
            currentResultSet = new CassandraResultSet(this, this.connection.getSession().execute(this.statement));
        }
        catch (Exception e)
        {
            throw new SQLTransientException(e);
        }
    }

    public void addBatch() throws SQLException
    {
        batchStatements.add(statement);
        this.statement = new BoundStatement(stmt);
        if(batchStatements.size()>MAX_ASYNC_QUERIES){
        	throw new SQLNonTransientException("Too many queries at once (" + batchStatements.size() + "). You must split your queries into more batches !");
        }
    }
    
    public int[] executeBatch() throws SQLException
    {
    	int[] returnCounts= new int[batchStatements.size()];
    	try{	    	
	    	List<ResultSetFuture> futures = new ArrayList<ResultSetFuture>();
	    	if (this.connection.debugMode) System.out.println("CQL statements : "+ batchStatements.size());
	    	for(BoundStatement q:batchStatements){
	    		for(int i=0; i<q.preparedStatement().getVariables().size(); i++){
	    			// Set parameters to null if unset
	    			if(!q.isSet(i)){
	            		q.setToNull(i);
	            	}
	            }
	    		if (this.connection.debugMode) System.out.println("CQL: "+ cql);
	    		q.setConsistencyLevel(this.connection.defaultConsistencyLevel);
	    		
	    		ResultSetFuture resultSetFuture = this.connection.getSession().executeAsync(q);
	    		futures.add(resultSetFuture);
	    	}
			
	    	int i=0;
			for (ResultSetFuture future : futures){
				future.getUninterruptibly();
				returnCounts[i]=1;
				i++;
			}
			
			
			// empty batch statement list after execution
			batchStatements = Lists.newArrayList();
    	}catch(Exception e){
    		// empty batch statement list after execution even if it failed...
    		batchStatements = Lists.newArrayList();
    		throw new SQLTransientException(e);
    	}
        
    	return returnCounts;
    }


    public void clearParameters() throws SQLException
    {
        checkNotClosed();
        bindValues.clear();
    }


    public boolean execute() throws SQLException
    {
        checkNotClosed();
        doExecute();
        return !(currentResultSet == null);
    }


    public ResultSet executeQuery() throws SQLException
    {
        checkNotClosed();
        doExecute();
        if (currentResultSet == null) throw new SQLNonTransientException(NO_RESULTSET);
        return currentResultSet;
    }


    public int executeUpdate() throws SQLException
    {
        checkNotClosed();
        doExecute();
        //if (currentResultSet != null) throw new SQLNonTransientException(NO_UPDATE_COUNT);
        // no update count available with the Datastax java driver
        return 0;
    }


    public ResultSetMetaData getMetaData() throws SQLException
    {
        return null;
    }


    public ParameterMetaData getParameterMetaData() throws SQLException
    {
        return null;
    }


    public void setBigDecimal(int parameterIndex, BigDecimal decimal) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        this.statement.setDecimal(parameterIndex-1, decimal);
        //bindValues.put(parameterIndex, decimal == null ? null : JdbcDecimal.instance.decompose(decimal));
    }


    public void setBoolean(int parameterIndex, boolean truth) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        //bindValues.put(parameterIndex, JdbcBoolean.instance.decompose(truth));
        this.statement.setBool(parameterIndex-1, truth);
    }


    public void setByte(int parameterIndex, byte b) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        //bindValues.put(parameterIndex, JdbcInteger.instance.decompose(BigInteger.valueOf(b)));
        //this.statement.setBytes(parameterIndex, ByteBuffer.);
    }


    public void setBytes(int parameterIndex, byte[] bytes) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        this.statement.setBytes(parameterIndex-1, ByteBuffer.wrap(bytes));
        //bindValues.put(parameterIndex, bytes == null ? null : ByteBuffer.wrap(bytes));
    }


    public void setDate(int parameterIndex, Date value) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        // date type data is handled as an 8 byte Long value of milliseconds since the epoch (handled in decompose() )
        //bindValues.put(parameterIndex, value == null ? null : JdbcDate.instance.decompose(value));
        this.statement.setTimestamp(parameterIndex-1, value);
    }


    public void setDate(int parameterIndex, Date date, Calendar cal) throws SQLException
    {
        // silently ignore the calendar argument it is not useful for the Cassandra implementation
        setDate(parameterIndex, date);
    }


    public void setDouble(int parameterIndex, double decimal) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        //bindValues.put(parameterIndex, JdbcDouble.instance.decompose(decimal));
        this.statement.setDouble(parameterIndex-1, decimal);
    }


    public void setFloat(int parameterIndex, float decimal) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        //bindValues.put(parameterIndex, JdbcFloat.instance.decompose(decimal));
        this.statement.setFloat(parameterIndex-1, decimal);
    }


    @SuppressWarnings("cast")
	public void setInt(int parameterIndex, int integer) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        //bindValues.put(parameterIndex, JdbcInt32.instance.decompose(integer));
        try{
        	this.statement.setInt(parameterIndex-1, integer);
        }catch(CodecNotFoundException e){        	        
    		if(e.getMessage().contains("Codec not found for requested operation: [varint <-> java.lang.Integer]")){
    			this.statement.setVarint(parameterIndex-1, BigInteger.valueOf((long)integer));
    		}
    	}
        
    }


    public void setLong(int parameterIndex, long bigint) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        //bindValues.put(parameterIndex, JdbcLong.instance.decompose(bigint));
        this.statement.setLong(parameterIndex-1, bigint);
    }


    public void setNString(int parameterIndex, String value) throws SQLException
    {
        // treat like a String
        setString(parameterIndex, value);
    }


    public void setNull(int parameterIndex, int sqlType) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        // silently ignore type for cassandra... just store an empty String
        this.statement.setToNull(parameterIndex-1);
        
    }


    public void setNull(int parameterIndex, int sqlType, String typeName) throws SQLException
    {
        // silently ignore type and type name for cassandra... just store an empty BB
        setNull(parameterIndex, sqlType);
    }

    

    public void setObject(int parameterIndex, Object object) throws SQLException
    {
   	
    	int targetType=0;
    	if(object.getClass().equals(java.lang.Long.class)){
    		targetType = Types.BIGINT;    		
    	} else if(object.getClass().equals(java.io.ByteArrayInputStream.class)){
    		targetType = Types.BINARY;
    	} else if(object.getClass().equals(java.lang.String.class)){
    		targetType = Types.VARCHAR;
    	} else if(object.getClass().equals(java.lang.Boolean.class)){
    		targetType = Types.BOOLEAN;
    	}else if(object.getClass().equals(java.math.BigDecimal.class)){
    		targetType = Types.DECIMAL;
    	}else if(object.getClass().equals(java.lang.Double.class)){
    		targetType = Types.DOUBLE;
    	}else if(object.getClass().equals(java.lang.Float.class)){
    		targetType = Types.FLOAT;
    	}else if(object.getClass().equals(java.net.Inet4Address.class)){
    		targetType = Types.OTHER;
    	}else if(object.getClass().equals(java.lang.Integer.class)){
    		targetType = Types.INTEGER;
    	}else if(object.getClass().equals(java.lang.String.class)){
    		targetType = Types.VARCHAR;
    	}else if(object.getClass().equals(java.sql.Timestamp.class)){
    		targetType = Types.TIMESTAMP;
    	}else if(object.getClass().equals(java.util.UUID.class)){
    		targetType = Types.ROWID;
    	}else{
    		targetType = Types.OTHER;
    	}
        setObject(parameterIndex, object, targetType, 0);        
    }

    public void setObject(int parameterIndex, Object object, int targetSqlType) throws SQLException
    {
        setObject(parameterIndex, object, targetSqlType, 0);
    }

    @SuppressWarnings({ "boxing", "unchecked" })
	public final void setObject(int parameterIndex, Object object, int targetSqlType, int scaleOrLength) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        
        switch(targetSqlType){          
        case Types.VARCHAR:
        	this.statement.setString(parameterIndex-1, object.toString());
        	break;
        case Types.BIGINT:
        	this.statement.setLong(parameterIndex-1, Long.parseLong(object.toString()));
        	break;
        case Types.BINARY:        	
        	byte[] array = new byte[((java.io.ByteArrayInputStream)object).available()];
        	try {
				((java.io.ByteArrayInputStream)object).read(array);
			} catch (IOException e1) {
				LOG.warn("Exception while setting object of BINARY type", e1);
			}
        	this.statement.setBytes(parameterIndex-1, (ByteBuffer.wrap((array))));
        	break;        	
        case Types.BOOLEAN:
        	this.statement.setBool(parameterIndex-1, (Boolean)object);
        	break;
        case Types.CHAR:
        	this.statement.setString(parameterIndex-1, object.toString());
        	break;
        case Types.CLOB:
        	this.statement.setString(parameterIndex-1, object.toString());
        	break;          
        case Types.TIMESTAMP:
        	this.statement.setTimestamp(parameterIndex-1, (Timestamp)object);
        	break;
        case Types.DECIMAL:
        	this.statement.setDecimal(parameterIndex-1, (BigDecimal)object);
        	break;
        case Types.DOUBLE:
        	this.statement.setDouble(parameterIndex-1, (Double)object);
        	break;
        case Types.FLOAT:
        	this.statement.setFloat(parameterIndex-1, (Float)object);
        	break;
        case Types.INTEGER:
        	try{
        		this.statement.setInt(parameterIndex-1, (Integer)object);
        	}catch(CodecNotFoundException e){
        		if(e.getMessage().contains("varint")){ // sucks but works
        			this.statement.setVarint(parameterIndex-1, BigInteger.valueOf(Long.parseLong(object.toString())));
        		}
        	}
        	break;
        case Types.DATE:
        	this.statement.setTimestamp(parameterIndex-1, (Date)object);
        	break;
        case Types.ROWID:
        	this.statement.setUUID(parameterIndex-1, (java.util.UUID)object);
        	break;
        case Types.OTHER:
        	if(object.getClass().equals(com.datastax.driver.core.TupleValue.class)){
        		this.statement.setTupleValue(parameterIndex-1, (com.datastax.driver.core.TupleValue) object);
        	}
        	if(object.getClass().equals(java.util.UUID.class)){
        		this.statement.setUUID(parameterIndex-1, (java.util.UUID) object);
        	}
        	if(object.getClass().equals(java.net.InetAddress.class) || object.getClass().equals(java.net.Inet4Address.class)){
        		this.statement.setInet(parameterIndex-1, (InetAddress) object);
        	}
        	else if ( List.class.isAssignableFrom(object.getClass()))
            {
              this.statement.setList(parameterIndex-1,handleAsList(object.getClass(), object));  
            }
            else if ( Set.class.isAssignableFrom(object.getClass()))
            {
            	this.statement.setSet(parameterIndex-1,handleAsSet(object.getClass(), object)); 
            }
            else if ( Map.class.isAssignableFrom(object.getClass()))
            {
            	this.statement.setMap(parameterIndex-1,handleAsMap(object.getClass(), object));              
            } 
                    	
        	break;
        }
        
        	
        
    }

    public void setRowId(int parameterIndex, RowId value) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        this.statement.setToNull(parameterIndex-1);
    }


    public void setShort(int parameterIndex, short smallint) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        this.statement.setInt(parameterIndex-1, smallint);
    }

    

    public void setString(int parameterIndex, String value) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        try{        	
        	this.statement.setString(parameterIndex-1, value);
        }catch(CodecNotFoundException e){        	       
        	// Big ugly hack in order to parse string representations of collections
        	// Yes, I'm ashamed...
           	if(e.getMessage().contains("set<")){
        		String itemType = e.getMessage().substring(e.getMessage().indexOf("<")+1, e.getMessage().indexOf(">"));
        		this.statement.setSet(parameterIndex-1, Utils.parseSet(itemType, value));        		        		        		
    		}else if(e.getMessage().contains("list<")){
        		String itemType = e.getMessage().substring(e.getMessage().indexOf("<")+1, e.getMessage().indexOf(">"));
        		this.statement.setList(parameterIndex-1, Utils.parseList(itemType, value));        	    		
    		}else if(e.getMessage().contains("map<")){
        		String[] kvTypes = e.getMessage().substring(e.getMessage().indexOf("<")+1, e.getMessage().indexOf(">")).replace(" ", "").split(",");
        		this.statement.setMap(parameterIndex-1, Utils.parseMap(kvTypes[0],kvTypes[1], value));
    		}
        }
        
    }


    @SuppressWarnings("boxing")
	public void setTime(int parameterIndex, Time value) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        // time type data is handled as an 8 byte Long value of milliseconds since the epoch
        bindValues.put(parameterIndex,
            value == null ? null : JdbcLong.instance.decompose(Long.valueOf(value.getTime())));
    }


    public void setTime(int parameterIndex, Time value, Calendar cal) throws SQLException
    {
        // silently ignore the calendar argument it is not useful for the Cassandra implementation
        setTime(parameterIndex, value);
    }


    public void setTimestamp(int parameterIndex, Timestamp value) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        // timestamp type data is handled as an 8 byte Long value of milliseconds since the epoch. Nanos are not supported and are ignored
        this.statement.setTimestamp(parameterIndex-1, new Date(value.getTime()));
    }


    public void setTimestamp(int parameterIndex, Timestamp value, Calendar cal) throws SQLException
    {
        // silently ignore the calendar argument it is not useful for the Cassandra implementation
        setTimestamp(parameterIndex, value);
    }


    public void setURL(int parameterIndex, URL value) throws SQLException
    {
        checkNotClosed();
        checkIndex(parameterIndex);
        // URl type data is handled as an string
        String url = value.toString();
        setString(parameterIndex, url);
    }
    
    public ResultSet getResultSet() throws SQLException
    {
        checkNotClosed();
        return currentResultSet;
    }
    
    @SuppressWarnings({ "unchecked", "rawtypes" })
	private static final <T> List handleAsList(Class<? extends Object> objectClass, Object object)
    {
        if (!List.class.isAssignableFrom(objectClass)) return null;        
        return (List<T>) object.getClass().cast(object);
                
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
	private static final <T> Set handleAsSet(Class<? extends Object> objectClass, Object object)
    {
        if (!Set.class.isAssignableFrom(objectClass)) return null;       
        
        return (Set<T>) object.getClass().cast(object);        
    }

    @SuppressWarnings({ "unchecked", "rawtypes" })
	private static final <K,V> HashMap  handleAsMap(Class<? extends Object> objectClass, Object object)
    {
        if (!Map.class.isAssignableFrom(objectClass)) return null;
       
        return (HashMap<K,V>) object.getClass().cast(object);
        
    }
    
    @SuppressWarnings("rawtypes")
	private static final Class<?> getCollectionElementType(Object maybeCollection)
    {
        Collection trial = (Collection) maybeCollection;
        if (trial.isEmpty()) return trial.getClass();
		return trial.iterator().next().getClass();
    }
   
    @SuppressWarnings({ "unused", "rawtypes" })
	private static final Class<?> getKeyElementType(Object maybeMap)
    {
        return getCollectionElementType(((Map) maybeMap).keySet());
    }
   
    @SuppressWarnings({ "unused", "rawtypes" })
	private static final Class<?> getValueElementType(Object maybeMap)
    {
        return getCollectionElementType(((Map) maybeMap).values());
    }


	@SuppressWarnings("boxing")
	@Override
	public void setBlob(int parameterIndex, Blob value) throws SQLException {
	
		InputStream in = value.getBinaryStream();

		setBlob(parameterIndex, in);

	}


	@Override
	public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException {
		int nRead;
		ByteArrayOutputStream buffer = new ByteArrayOutputStream();
		byte[] data = new byte[16384];

		try {
			while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
			  buffer.write(data, 0, nRead);
			}
		} catch (IOException e) {
			throw new SQLNonTransientException(e);
		}

		try {
			buffer.flush();
		} catch (IOException e) {
			throw new SQLNonTransientException(e);
		}

		this.statement.setBytes(parameterIndex-1, (ByteBuffer.wrap((buffer.toByteArray()))));
		
	}


	@Override
	public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException {
		try {
		    String targetString = CharStreams.toString(reader);
		    reader.close();
		    
		    setString(parameterIndex, targetString);
		} catch (IOException e) {
			// TODO Auto-generated catch block
			throw new SQLNonTransientException(e);
		}
		
	}
   
}