package servicenow.datamart;

import servicenow.api.*;

import java.io.UnsupportedEncodingException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Types;
import java.util.Calendar;
import java.util.TimeZone;
import java.util.regex.Pattern;
import org.slf4j.Logger;

public abstract class DatabaseStatement {

	final Database db;
	final String sqlTableName;
	final Generator generator;
	final ColumnDefinitions columns;
	final String templateName;
	final String stmtText;
	final PreparedStatement stmt;
	Record lastRec;

    private final Pattern dateTimePattern = 
        	Pattern.compile("\\d\\d\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d");

	public final Calendar GMT = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
    
	final private Logger logger = Log.logger(this.getClass());
	final boolean traceEnabled;
	
	public DatabaseStatement(Database db, String templateName, String sqlTableName, ColumnDefinitions columns) throws SQLException {
		this.db = db;
		this.sqlTableName = sqlTableName;
		this.templateName = templateName;
		this.generator = db.getGenerator();
		Connection dbc = db.getConnection();
		this.columns = columns;
		this.stmtText = buildStatement();
		logger.debug(Log.SCHEMA, stmtText);
		this.stmt = dbc.prepareStatement(stmtText);
		traceEnabled = logger.isTraceEnabled(Log.BIND);
	}

	abstract String buildStatement() throws SQLException;
		
	protected void bindField(int bindCol, String value) throws SQLException {
		stmt.setString(bindCol, value);
	}
	
	/**
	 * Get a value from a Glide Record and bind it to a variable in prepared statement.
	 * 
	 * @param bindCol Index (starting with 1) of the variable within the statement.
	 * @param rec Glide Record which serves as a source of the data.
	 * @param glideCol Index (starting with 0) of the variable in the columns array.
	 * @throws SQLException
	 */	
	protected void bindField(int bindCol, Record rec, int glideCol) throws SQLException {
		DatabaseFieldDefinition defn = columns.get(glideCol);
		String glidename = defn.getGlideName();
		String value = rec.getValue(glidename);
		try {
			bindField(bindCol, rec, defn, value);
		}
		catch (SQLException|NumberFormatException e) {
			logger.error(Log.PROCESS, 
					String.format("bindField %s=\"%s\"", glidename, value));
			throw e;
		}		
	}
	
	protected void bindField(int bindCol, Record rec, DatabaseFieldDefinition d, String value) throws SQLException {
		String glidename = d.getGlideName();
		int sqltype = d.sqltype;
		// If value is null then bind to null and exit
		if (value == null) {
			stmt.setNull(bindCol, sqltype);
			return;
		}		
		if ((sqltype == Types.NUMERIC || sqltype == Types.DECIMAL || 
				sqltype == Types.INTEGER || sqltype == Types.DOUBLE) && 
				value.length() == 19) {
			// If the target data type is numeric
			// and the value appears to be a date (dddd-dd-dd dd:dd:dd)
			// then it must be a duration
			// so try to convert it to a number of seconds
			if (dateTimePattern.matcher(value).matches()) {
				try {
					DateTime timestamp = new DateTime(value, DateTime.DATE_TIME);
					long seconds = timestamp.toDate().getTime() / 1000L;
					if (traceEnabled)
						logger.trace(Log.BIND, "date " + glidename + " " + value + "=" + seconds);
					if (seconds < 0L) {
						logger.warn(Log.PROCESS, rec.getKey() + " duration underflow: " +
							glidename + "=" + value);
						value = null;
					}
					else if (seconds > 999999999L) {
						logger.warn(Log.PROCESS, rec.getKey() + " duration overflow: " +
							glidename + "=" + value);
						value = null;
					}
					else {
						value = Long.toString(seconds);
					}
				} catch (InvalidDateTimeException e) {
					logger.warn(Log.PROCESS, rec.getKey() + " duration error: " +
							glidename + "=" + value);
					value = null;
				}
				if (value == null) {
					stmt.setNull(bindCol, sqltype);
					return;					
				}
			}
		}
		assert value != null;
		// If the SQL type is VARCHAR, then check for an over-size value
		// and truncate if necessary
		if (sqltype == Types.VARCHAR || sqltype == Types.CHAR) {
			int oldSize = value.length();
			int maxSize = d.getSize();
			if (value.length() > maxSize) {
				value = value.substring(0,  maxSize);
			}
			if (generator.getDialectName().equals("oracle_")) {
				// This is a workaround for an apparent bug in the Oracle JDBC 
				// driver which occasionally generates an ORA-01461 error when 
				// inserting from a text field containing multi-byte characters
				// into a VARCHAR2 column.
				// Keep chopping more characters off the end of the string until
				// the number of BYTES is less than the field size.
				try {
					while (value.getBytes("UTF8").length > maxSize)
						value = value.substring(0, value.length() - 1);										
				}
				catch (UnsupportedEncodingException e) {
					throw new RuntimeException(e);
				}
			}
			if (db.isPostgresql()) {
				// PostgreSQL doesn't support storing NULL characters in text fields
				value = value.replaceAll("\u0000", "");
			}
			if (value.length() != oldSize) {
				String message = rec.getKey() + " truncated: " + glidename +
					" from " + oldSize + " to " + value.length();
				if (db.getWarnOnTruncate())
					logger.warn(Log.PROCESS, message);
				else
					logger.debug(Log.PROCESS, message);
			}
		}
		if (traceEnabled) {
			int len = (value == null ? 0 : value.length());
			logger.trace(Log.BIND, String.format("bind %d %s %s=%s (len=%d)",
					bindCol, sqlTypeName(sqltype), glidename, value, len));
		}
		assert value != null;
		switch (sqltype) {
		case Types.DATE :
			DateTime dt;
			try {
				dt = new DateTime(value);
				java.sql.Date sqldate = new java.sql.Date(dt.getMillisec());
				stmt.setDate(bindCol, sqldate, GMT);
			}
			catch (InvalidDateTimeException e) {
				logger.warn(Log.PROCESS, rec.getKey() + " date error: " +
						glidename + "=" + value);
				stmt.setDate(bindCol,  null);			
			}
			break;
		case Types.TIMESTAMP :
			// If the SQL type is TIMESTAMP, then try to bind the field to a java.sql.Timesetamp.
			// Note that in Oracle the DATE fields have a java.sql type of TIMESTAMP.
			DateTime ts;
			try { 
				ts = new DateTime(value);
				java.sql.Timestamp sqlts = new java.sql.Timestamp(ts.getMillisec());
				assert sqlts.getTime() == ts.getMillisec();
				if (traceEnabled)
					logger.trace(Log.BIND, String.format("timestamp %s=%s", glidename, sqlts.toString()));
				stmt.setTimestamp(bindCol, sqlts, GMT);
			}
			catch (InvalidDateTimeException e) {
				logger.warn(Log.PROCESS, rec.getKey() + " timestamp error: " +
						glidename + "=" + value);
				stmt.setTimestamp(bindCol, null);				
			}
			break;
		case Types.BOOLEAN :
		case Types.BIT :
			if (value.equals("1") || value.equalsIgnoreCase("true"))
				stmt.setBoolean(bindCol, true);
			else if (value.equals("0") || value.equalsIgnoreCase("false"))
				stmt.setBoolean(bindCol,  false);
			else {
				logger.warn(Log.PROCESS, rec.getKey() + "boolean error: " +
						glidename + "=" + value);
				stmt.setNull(bindCol, sqltype);
			}
			break;
		case Types.TINYINT :
			if (value.equalsIgnoreCase("false")) value = "0";
			if (value.equalsIgnoreCase("true"))  value = "1";
			stmt.setByte(bindCol, Byte.parseByte(value));
			break;
		case Types.SMALLINT :
			if (value.equalsIgnoreCase("false")) value = "0";
			if (value.equalsIgnoreCase("true"))  value = "1";
			stmt.setShort(bindCol, Short.parseShort(value));
			break;
		case Types.INTEGER :
			// This is a workaround for the fact that ServiceNow includes decimal portions
			// in integer fields, which can cause JDBC to choke.
			int p = value.indexOf('.');
			if (p > -1) {
				String message = rec.getKey() + " decimal truncated: " +
						glidename + "=" + value;
				if (db.getWarnOnTruncate())
					logger.warn(Log.PROCESS, message);
				else
					logger.debug(Log.PROCESS, message);
				value = value.substring(0,  p);
			}
			if (value.length() == 0) value = "0";
			if (value.equalsIgnoreCase("false")) value = "0";
			if (value.equalsIgnoreCase("true"))  value = "1";
			stmt.setInt(bindCol, Integer.parseInt(value));
			break;
		case Types.DOUBLE :
		case Types.FLOAT :
		case Types.NUMERIC :
		case Types.DECIMAL :
			if (value.equalsIgnoreCase("false")) value = "0";
			if (value.equalsIgnoreCase("true"))  value = "1";
			stmt.setDouble(bindCol, Double.parseDouble(value));
			break;
		default :
			if (db.isPostgresql()) {
				// PostgreSQL doesn't support storing NULL characters in text fields
				value = value.replaceAll("\u0000", "");
			}
			stmt.setString(bindCol, value);			
		}
	}

	private static String sqlTypeName(int sqltype) {
		switch (sqltype) {
			case Types.ARRAY:         return "ARRAY";
			case Types.BIGINT:        return "BIGINT";
			case Types.BINARY:        return "BINARY";
			case Types.BIT:           return "BIT";
			case Types.BLOB:          return "BLOB";
			case Types.BOOLEAN:       return "BOOLEAN";
			case Types.CHAR:          return "CHAR";
			case Types.CLOB:          return "CLOB";
			case Types.DATALINK:      return "DATALINK";
			case Types.DATE:          return "DATE";
			case Types.DECIMAL:       return "DECIMAL";
			case Types.DISTINCT:      return "DISTINCT";
			case Types.DOUBLE:        return "DOUBLE";
			case Types.FLOAT:         return "FLOAT";
			case Types.INTEGER:       return "INTEGER";
			case Types.JAVA_OBJECT:   return "JAVA_OBJECT";
			case Types.LONGNVARCHAR:  return "LONGNVARCHAR";
			case Types.LONGVARBINARY: return "LONGVARBINARY";
			case Types.LONGVARCHAR:   return "LONGVARCHAR";
			case Types.NCHAR:         return "NCHAR";
			case Types.NCLOB:         return "NCLOB";
			case Types.NULL:          return "NULL";
			case Types.NUMERIC:       return "NUMERIC";
			case Types.NVARCHAR:      return "NVARCHAR";
			case Types.OTHER:         return "OTHER";
			case Types.REAL:          return "REAL";
			case Types.REF:           return "REF";
			case Types.ROWID:         return "ROWID";
			case Types.SMALLINT:      return "SMALLINT";
			case Types.SQLXML:        return "SQLXML";
			case Types.STRUCT:        return "STRUCT";
			case Types.TIME:          return "TIME";
			case Types.TIMESTAMP:     return "TIMESTAMP";
			case Types.TINYINT:       return "TINYINT";
			case Types.VARBINARY:     return "VARBINARY";
			case Types.VARCHAR:       return "VARCHAR";
			default: return Integer.toString(sqltype);
		}
	}
}