/*
 Copyright (c) 2002, 2012, Oracle and/or its affiliates. All rights reserved.
 

  The MySQL Connector/J is licensed under the terms of the GPLv2
  <http://www.gnu.org/licenses/old-licenses/gpl-2.0.html>, like most MySQL Connectors.
  There are special exceptions to the terms and conditions of the GPLv2 as it is applied to
  this software, see the FLOSS License Exception
  <http://www.mysql.com/about/legal/licensing/foss-exception.html>.

  This program 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; version 2
  of the License.

  This program 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 this
  program; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth
  Floor, Boston, MA 02110-1301  USA



 */

/**
 * EscapeProcessor performs all escape code processing as outlined in the JDBC
 * spec by JavaSoft.
 */
package com.mysql.jdbc;

import java.sql.SQLException;
import java.sql.Time;
import java.sql.Timestamp;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Collections;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.StringTokenizer;
import java.util.TimeZone;

class EscapeProcessor {
	private static Map<String, String> JDBC_CONVERT_TO_MYSQL_TYPE_MAP;

	private static Map<String, String> JDBC_NO_CONVERT_TO_MYSQL_EXPRESSION_MAP;

	static {
		Map<String, String> tempMap = new HashMap<String, String>();

		tempMap.put("BIGINT", "0 + ?");
		tempMap.put("BINARY", "BINARY");
		tempMap.put("BIT", "0 + ?");
		tempMap.put("CHAR", "CHAR");
		tempMap.put("DATE", "DATE");
		tempMap.put("DECIMAL", "0.0 + ?");
		tempMap.put("DOUBLE", "0.0 + ?");
		tempMap.put("FLOAT", "0.0 + ?");
		tempMap.put("INTEGER", "0 + ?");
		tempMap.put("LONGVARBINARY", "BINARY");
		tempMap.put("LONGVARCHAR", "CONCAT(?)");
		tempMap.put("REAL", "0.0 + ?");
		tempMap.put("SMALLINT", "CONCAT(?)");
		tempMap.put("TIME", "TIME");
		tempMap.put("TIMESTAMP", "DATETIME");
		tempMap.put("TINYINT", "CONCAT(?)");
		tempMap.put("VARBINARY", "BINARY");
		tempMap.put("VARCHAR", "CONCAT(?)");

		JDBC_CONVERT_TO_MYSQL_TYPE_MAP = Collections.unmodifiableMap(tempMap);

		tempMap = new HashMap<String, String>(JDBC_CONVERT_TO_MYSQL_TYPE_MAP);

		tempMap.put("BINARY", "CONCAT(?)");
		tempMap.put("CHAR", "CONCAT(?)");
		tempMap.remove("DATE");
		tempMap.put("LONGVARBINARY", "CONCAT(?)");
		tempMap.remove("TIME");
		tempMap.remove("TIMESTAMP");
		tempMap.put("VARBINARY", "CONCAT(?)");

		JDBC_NO_CONVERT_TO_MYSQL_EXPRESSION_MAP = Collections
				.unmodifiableMap(tempMap);

	}

	/**
	 * Escape process one string
	 * 
	 * @param sql
	 *            the SQL to escape process.
	 * 
	 * @return the SQL after it has been escape processed.
	 * 
	 * @throws java.sql.SQLException
	 *             DOCUMENT ME!
	 * @throws SQLException
	 *             DOCUMENT ME!
	 */
	public static final Object escapeSQL(String sql,
			boolean serverSupportsConvertFn, MySQLConnection conn)
			throws java.sql.SQLException {
		boolean replaceEscapeSequence = false;
		String escapeSequence = null;

		if (sql == null) {
			return null;
		}

		/*
		 * Short circuit this code if we don't have a matching pair of "{}". -
		 * Suggested by Ryan Gustafason
		 */
		int beginBrace = sql.indexOf('{');
		int nextEndBrace = (beginBrace == -1) ? (-1) : sql.indexOf('}',
				beginBrace);

		if (nextEndBrace == -1) {
			return sql;
		}

		StringBuffer newSql = new StringBuffer();

		EscapeTokenizer escapeTokenizer = new EscapeTokenizer(sql);

		byte usesVariables = StatementImpl.USES_VARIABLES_FALSE;
		boolean callingStoredFunction = false;

		while (escapeTokenizer.hasMoreTokens()) {
			String token = escapeTokenizer.nextToken();

			if (token.length() != 0) {
				if (token.charAt(0) == '{') { // It's an escape code

					if (!token.endsWith("}")) {
						throw SQLError
								.createSQLException("Not a valid escape sequence: "
										+ token, conn.getExceptionInterceptor());
					}

					if (token.length() > 2) {
						int nestedBrace = token.indexOf('{', 2);

						if (nestedBrace != -1) {
							StringBuffer buf = new StringBuffer(token
									.substring(0, 1));

							Object remainingResults = escapeSQL(token
									.substring(1, token.length() - 1),
									serverSupportsConvertFn, conn);

							String remaining = null;

							if (remainingResults instanceof String) {
								remaining = (String) remainingResults;
							} else {
								remaining = ((EscapeProcessorResult) remainingResults).escapedSql;

								if (usesVariables != StatementImpl.USES_VARIABLES_TRUE) {
									usesVariables = ((EscapeProcessorResult) remainingResults).usesVariables;
								}
							}

							buf.append(remaining);

							buf.append('}');

							token = buf.toString();
						}
					}

					// nested escape code
					// Compare to tokens with _no_ whitespace
					String collapsedToken = removeWhitespace(token);

					/*
					 * Process the escape code
					 */
					if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{escape")) {
						try {
							StringTokenizer st = new StringTokenizer(token,
									" '");
							st.nextToken(); // eat the "escape" token
							escapeSequence = st.nextToken();

							if (escapeSequence.length() < 3) {
								newSql.append(token); // it's just part of the
														// query, push possible
														// syntax errors onto
														// server's shoulders
							} else {

								escapeSequence = escapeSequence.substring(1,
										escapeSequence.length() - 1);
								replaceEscapeSequence = true;
							}
						} catch (java.util.NoSuchElementException e) {
							newSql.append(token); // it's just part of the
													// query, push possible
													// syntax errors onto
													// server's shoulders
						}
					} else if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{fn")) {
						int startPos = token.toLowerCase().indexOf("fn ") + 3;
						int endPos = token.length() - 1; // no }

						String fnToken = token.substring(startPos, endPos);

						// We need to handle 'convert' by ourselves

						if (StringUtils.startsWithIgnoreCaseAndWs(fnToken,
								"convert")) {
							newSql.append(processConvertToken(fnToken,
									serverSupportsConvertFn, conn));
						} else {
							// just pass functions right to the DB
							newSql.append(fnToken);
						}
					} else if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{d")) {
						int startPos = token.indexOf('\'') + 1;
						int endPos = token.lastIndexOf('\''); // no }

						if ((startPos == -1) || (endPos == -1)) {
							newSql.append(token); // it's just part of the
													// query, push possible
													// syntax errors onto
													// server's shoulders
						} else {

							String argument = token.substring(startPos, endPos);

							try {
								StringTokenizer st = new StringTokenizer(
										argument, " -");
								String year4 = st.nextToken();
								String month2 = st.nextToken();
								String day2 = st.nextToken();
								String dateString = "'" + year4 + "-" + month2
										+ "-" + day2 + "'";
								newSql.append(dateString);
							} catch (java.util.NoSuchElementException e) {
								throw SQLError.createSQLException(
										"Syntax error for DATE escape sequence '"
												+ argument + "'", "42000", conn.getExceptionInterceptor());
							}
						}
					} else if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{ts")) {
						processTimestampToken(conn, newSql, token);
					} else if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{t")) {
						processTimeToken(conn, newSql, token);
					} else if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{call")
							|| StringUtils.startsWithIgnoreCase(collapsedToken,
									"{?=call")) {

						int startPos = StringUtils.indexOfIgnoreCase(token,
								"CALL") + 5;
						int endPos = token.length() - 1;

						if (StringUtils.startsWithIgnoreCase(collapsedToken,
								"{?=call")) {
							callingStoredFunction = true;
							newSql.append("SELECT ");
							newSql.append(token.substring(startPos, endPos));
						} else {
							callingStoredFunction = false;
							newSql.append("CALL ");
							newSql.append(token.substring(startPos, endPos));
						}

						for (int i = endPos - 1; i >= startPos; i--) {
							char c = token.charAt(i);

							if (Character.isWhitespace(c)) {
								continue;
							}

							if (c != ')') {
								newSql.append("()"); // handle no-parenthesis
														// no-arg call not
														// supported
								// by MySQL parser
							}

							break;
						}
					} else if (StringUtils.startsWithIgnoreCase(collapsedToken,
							"{oj")) {
						// MySQL already handles this escape sequence
						// because of ODBC. Cool.
						newSql.append(token);
					} else {
						// not an escape code, just part of the query
						newSql.append(token);
					}
				} else {
					newSql.append(token); // it's just part of the query
				}
			}
		}

		String escapedSql = newSql.toString();

		//
		// FIXME: Let MySQL do this, however requires
		// lightweight parsing of statement
		//
		if (replaceEscapeSequence) {
			String currentSql = escapedSql;

			while (currentSql.indexOf(escapeSequence) != -1) {
				int escapePos = currentSql.indexOf(escapeSequence);
				String lhs = currentSql.substring(0, escapePos);
				String rhs = currentSql.substring(escapePos + 1, currentSql
						.length());
				currentSql = lhs + "\\" + rhs;
			}

			escapedSql = currentSql;
		}

		EscapeProcessorResult epr = new EscapeProcessorResult();
		epr.escapedSql = escapedSql;
		epr.callingStoredFunction = callingStoredFunction;

		if (usesVariables != StatementImpl.USES_VARIABLES_TRUE) {
			if (escapeTokenizer.sawVariableUse()) {
				epr.usesVariables = StatementImpl.USES_VARIABLES_TRUE;
			} else {
				epr.usesVariables = StatementImpl.USES_VARIABLES_FALSE;
			}
		}

		return epr;
	}

	private static void processTimeToken(MySQLConnection conn,
			StringBuffer newSql, String token) throws SQLException {
		int startPos = token.indexOf('\'') + 1;
		int endPos = token.lastIndexOf('\''); // no }

		if ((startPos == -1) || (endPos == -1)) {
			newSql.append(token); // it's just part of the
									// query, push possible
									// syntax errors onto
									// server's shoulders
		} else {

			String argument = token.substring(startPos, endPos);

			try {
				StringTokenizer st = new StringTokenizer(
						argument, " :");
				String hour = st.nextToken();
				String minute = st.nextToken();
				String second = st.nextToken();

				if (conn != null && (!conn.getUseTimezone() || !conn.getUseLegacyDatetimeCode())) {
					String timeString = "'" + hour + ":"
							+ minute + ":" + second + "'";
					newSql.append(timeString);
				} else {
					Calendar sessionCalendar = null;

					if (conn != null) {
						sessionCalendar = conn
								.getCalendarInstanceForSessionOrNew();
					} else {
						sessionCalendar = new GregorianCalendar();
					}

					try {
						int hourInt = Integer.parseInt(hour);
						int minuteInt = Integer
								.parseInt(minute);
						int secondInt = Integer
								.parseInt(second);

						synchronized (sessionCalendar) {
							Time toBeAdjusted = TimeUtil
									.fastTimeCreate(
											sessionCalendar,
											hourInt, minuteInt,
											secondInt, conn.getExceptionInterceptor());

							Time inServerTimezone = TimeUtil
									.changeTimezone(
											conn,
											sessionCalendar,
											null,
											toBeAdjusted,
											sessionCalendar
													.getTimeZone(),
											conn
													.getServerTimezoneTZ(),
											false);

							newSql.append("'");
							newSql.append(inServerTimezone
									.toString());
							newSql.append("'");
						}

					} catch (NumberFormatException nfe) {
						throw SQLError
								.createSQLException(
										"Syntax error in TIMESTAMP escape sequence '"
												+ token + "'.",
										SQLError.SQL_STATE_ILLEGAL_ARGUMENT, conn.getExceptionInterceptor());
					}
				}
			} catch (java.util.NoSuchElementException e) {
				throw SQLError.createSQLException(
						"Syntax error for escape sequence '"
								+ argument + "'", "42000", conn.getExceptionInterceptor());
			}
		}
	}

	private static void processTimestampToken(MySQLConnection conn,
			StringBuffer newSql, String token) throws SQLException {
		int startPos = token.indexOf('\'') + 1;
		int endPos = token.lastIndexOf('\''); // no }

		if ((startPos == -1) || (endPos == -1)) {
			newSql.append(token); // it's just part of the
									// query, push possible
									// syntax errors onto
									// server's shoulders
		} else {

			String argument = token.substring(startPos, endPos);

			try {
				if (conn != null && !conn.getUseLegacyDatetimeCode()) {
					Timestamp ts = Timestamp.valueOf(argument);
					SimpleDateFormat tsdf = new SimpleDateFormat(
							"''yyyy-MM-dd HH:mm:ss''", Locale.US); //$NON-NLS-1$

					tsdf
							.setTimeZone(conn
									.getServerTimezoneTZ());

					newSql.append(tsdf.format(ts));
				} else {
					StringTokenizer st = new StringTokenizer(
							argument, " .-:");
					try {
						String year4 = st.nextToken();
						String month2 = st.nextToken();
						String day2 = st.nextToken();
						String hour = st.nextToken();
						String minute = st.nextToken();
						String second = st.nextToken();

						/*
						 * For now, we get the fractional
						 * seconds part, but we don't use it, as
						 * MySQL doesn't support it in it's
						 * TIMESTAMP data type
						 * 
						 * String fractionalSecond = "";
						 * 
						 * if (st.hasMoreTokens()) {
						 * fractionalSecond = st.nextToken(); }
						 */

						/*
						 * Use the full format because number
						 * format will not work for "between"
						 * clauses.
						 * 
						 * Ref. Mysql Docs
						 * 
						 * You can specify DATETIME, DATE and
						 * TIMESTAMP values using any of a
						 * common set of formats:
						 * 
						 * As a string in either 'YYYY-MM-DD
						 * HH:MM:SS' or 'YY-MM-DD HH:MM:SS'
						 * format.
						 * 
						 * Thanks to Craig Longman for pointing
						 * out this bug
						 */

						if (conn != null &&
								!conn.getUseTimezone()
								&& !conn.getUseJDBCCompliantTimezoneShift()) {
							newSql.append("'").append(year4)
									.append("-").append(month2)
									.append("-").append(day2)
									.append(" ").append(hour)
									.append(":").append(minute)
									.append(":").append(second)
									.append("'");
						} else {
							Calendar sessionCalendar;

							if (conn != null) {
								sessionCalendar = conn
										.getCalendarInstanceForSessionOrNew();
							} else {
								sessionCalendar = new GregorianCalendar();
								sessionCalendar
										.setTimeZone(TimeZone
												.getTimeZone("GMT"));
							}

							try {
								int year4Int = Integer
										.parseInt(year4);
								int month2Int = Integer
										.parseInt(month2);
								int day2Int = Integer
										.parseInt(day2);
								int hourInt = Integer
										.parseInt(hour);
								int minuteInt = Integer
										.parseInt(minute);
								int secondInt = Integer
										.parseInt(second);

								synchronized (sessionCalendar) {
									boolean useGmtMillis = conn
											.getUseGmtMillisForDatetimes();

									Timestamp toBeAdjusted = TimeUtil
											.fastTimestampCreate(
													useGmtMillis,
													useGmtMillis ? Calendar
															.getInstance(TimeZone
																	.getTimeZone("GMT"))
															: null,
													sessionCalendar,
													year4Int,
													month2Int,
													day2Int,
													hourInt,
													minuteInt,
													secondInt,
													0);

									Timestamp inServerTimezone = TimeUtil
											.changeTimezone(
													conn,
													sessionCalendar,
													null,
													toBeAdjusted,
													sessionCalendar
															.getTimeZone(),
													conn
															.getServerTimezoneTZ(),
													false);

									newSql.append("'");

									String timezoneLiteral = inServerTimezone
											.toString();

									int indexOfDot = timezoneLiteral
											.indexOf(".");

									if (indexOfDot != -1) {
										timezoneLiteral = timezoneLiteral
												.substring(0,
														indexOfDot);
									}

									newSql
											.append(timezoneLiteral);
								}

								newSql.append("'");

							} catch (NumberFormatException nfe) {
								throw SQLError
										.createSQLException(
												"Syntax error in TIMESTAMP escape sequence '"
														+ token
														+ "'.",
												SQLError.SQL_STATE_ILLEGAL_ARGUMENT, conn.getExceptionInterceptor());
							}
						}
					} catch (java.util.NoSuchElementException e) {
						throw SQLError.createSQLException(
								"Syntax error for TIMESTAMP escape sequence '"
										+ argument + "'",
								"42000", conn.getExceptionInterceptor());
					}
				}
			} catch (IllegalArgumentException illegalArgumentException) {
				SQLException sqlEx = SQLError
						.createSQLException(
								"Syntax error for TIMESTAMP escape sequence '"
										+ argument + "'",
								"42000", conn.getExceptionInterceptor());
				sqlEx.initCause(illegalArgumentException);

				throw sqlEx;
			}
		}
	}

	/**
	 * Re-writes {fn convert (expr, type)} as cast(expr AS type)
	 * 
	 * @param functionToken
	 * @return
	 * @throws SQLException
	 */
	private static String processConvertToken(String functionToken,
			boolean serverSupportsConvertFn, MySQLConnection conn) throws SQLException {
		// The JDBC spec requires these types:
		//
		// BIGINT
		// BINARY
		// BIT
		// CHAR
		// DATE
		// DECIMAL
		// DOUBLE
		// FLOAT
		// INTEGER
		// LONGVARBINARY
		// LONGVARCHAR
		// REAL
		// SMALLINT
		// TIME
		// TIMESTAMP
		// TINYINT
		// VARBINARY
		// VARCHAR

		// MySQL supports these types:
		//
		// BINARY
		// CHAR
		// DATE
		// DATETIME
		// SIGNED (integer)
		// UNSIGNED (integer)
		// TIME

		int firstIndexOfParen = functionToken.indexOf("(");

		if (firstIndexOfParen == -1) {
			throw SQLError
					.createSQLException(
							"Syntax error while processing {fn convert (... , ...)} token, missing opening parenthesis in token '"
									+ functionToken + "'.",
							SQLError.SQL_STATE_SYNTAX_ERROR, conn.getExceptionInterceptor());
		}

		int indexOfComma = functionToken.lastIndexOf(",");

		if (indexOfComma == -1) {
			throw SQLError
					.createSQLException(
							"Syntax error while processing {fn convert (... , ...)} token, missing comma in token '"
									+ functionToken + "'.",
							SQLError.SQL_STATE_SYNTAX_ERROR, conn.getExceptionInterceptor());
		}

		int indexOfCloseParen = functionToken.indexOf(')', indexOfComma);

		if (indexOfCloseParen == -1) {
			throw SQLError
					.createSQLException(
							"Syntax error while processing {fn convert (... , ...)} token, missing closing parenthesis in token '"
									+ functionToken + "'.",
							SQLError.SQL_STATE_SYNTAX_ERROR, conn.getExceptionInterceptor());

		}

		String expression = functionToken.substring(firstIndexOfParen + 1,
				indexOfComma);
		String type = functionToken.substring(indexOfComma + 1,
				indexOfCloseParen);

		String newType = null;

		String trimmedType = type.trim();

		if (StringUtils.startsWithIgnoreCase(trimmedType, "SQL_")) {
			trimmedType = trimmedType.substring(4, trimmedType.length());
		}

		if (serverSupportsConvertFn) {
			newType = JDBC_CONVERT_TO_MYSQL_TYPE_MAP.get(trimmedType
					.toUpperCase(Locale.ENGLISH));
		} else {
			newType = JDBC_NO_CONVERT_TO_MYSQL_EXPRESSION_MAP
					.get(trimmedType.toUpperCase(Locale.ENGLISH));

			// We need a 'special' check here to give a better error message. If
			// we're in this
			// block, the version of MySQL we're connected to doesn't support
			// CAST/CONVERT,
			// so we can't re-write some data type conversions
			// (date,time,timestamp, datetime)

			if (newType == null) {
				throw SQLError
						.createSQLException(
								"Can't find conversion re-write for type '"
										+ type
										+ "' that is applicable for this server version while processing escape tokens.",
								SQLError.SQL_STATE_GENERAL_ERROR, conn.getExceptionInterceptor());
			}
		}

		if (newType == null) {
			throw SQLError.createSQLException("Unsupported conversion type '"
					+ type.trim() + "' found while processing escape token.",
					SQLError.SQL_STATE_GENERAL_ERROR, conn.getExceptionInterceptor());
		}

		int replaceIndex = newType.indexOf("?");

		if (replaceIndex != -1) {
			StringBuffer convertRewrite = new StringBuffer(newType.substring(0,
					replaceIndex));
			convertRewrite.append(expression);
			convertRewrite.append(newType.substring(replaceIndex + 1, newType
					.length()));

			return convertRewrite.toString();
		}

		StringBuffer castRewrite = new StringBuffer("CAST(");
		castRewrite.append(expression);
		castRewrite.append(" AS ");
		castRewrite.append(newType);
		castRewrite.append(")");

		return castRewrite.toString();

	}

	/**
	 * Removes all whitespace from the given String. We use this to make escape
	 * token comparison white-space ignorant.
	 * 
	 * @param toCollapse
	 *            the string to remove the whitespace from
	 * 
	 * @return a string with _no_ whitespace.
	 */
	private static String removeWhitespace(String toCollapse) {
		if (toCollapse == null) {
			return null;
		}

		int length = toCollapse.length();

		StringBuffer collapsed = new StringBuffer(length);

		for (int i = 0; i < length; i++) {
			char c = toCollapse.charAt(i);

			if (!Character.isWhitespace(c)) {
				collapsed.append(c);
			}
		}

		return collapsed.toString();
	}
}
