/*****************************************************************
 *   Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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
 *
 *    https://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 org.apache.cayenne.access.jdbc;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.apache.cayenne.CayenneRuntimeException;
import org.apache.cayenne.ResultIterator;
import org.apache.cayenne.access.DataNode;
import org.apache.cayenne.access.OperationObserver;
import org.apache.cayenne.access.jdbc.reader.RowReader;
import org.apache.cayenne.access.translator.ParameterBinding;
import org.apache.cayenne.access.types.ExtendedType;
import org.apache.cayenne.access.types.ExtendedTypeMap;
import org.apache.cayenne.dba.DbAdapter;
import org.apache.cayenne.dba.TypesMapping;
import org.apache.cayenne.map.DbAttribute;
import org.apache.cayenne.map.DbEntity;
import org.apache.cayenne.map.DefaultScalarResultSegment;
import org.apache.cayenne.map.ObjAttribute;
import org.apache.cayenne.map.ObjEntity;
import org.apache.cayenne.query.QueryMetadata;
import org.apache.cayenne.query.SQLAction;
import org.apache.cayenne.query.SQLTemplate;
import org.apache.cayenne.util.Util;

/**
 * Implements a strategy for execution of SQLTemplates.
 *
 * @since 1.2 replaces SQLTemplateExecutionPlan
 */
public class SQLTemplateAction implements SQLAction {

	protected SQLTemplate query;
	protected QueryMetadata queryMetadata;

	protected DbEntity dbEntity;
	protected DataNode dataNode;
	protected DbAdapter dbAdapter;

	/**
	 * @since 4.0
	 */
	public SQLTemplateAction(SQLTemplate query, DataNode dataNode) {
		this.query = query;
		this.dataNode = dataNode;
		this.queryMetadata = query.getMetaData(dataNode.getEntityResolver());
		this.dbEntity = queryMetadata.getDbEntity();

		// using unwrapped adapter to check for the right SQL flavor...
		this.dbAdapter = dataNode.getAdapter().unwrap();
	}

	/**
	 * Returns unwrapped DbAdapter used to find correct SQL for a given DB.
	 */
	public DbAdapter getAdapter() {
		return dbAdapter;
	}

	/**
	 * Runs a SQLTemplate query, collecting all results. If a callback expects
	 * an iterated result, result processing is stopped after the first
	 * ResultSet is encountered.
	 */
	@Override
	public void performAction(Connection connection, OperationObserver callback) throws SQLException, Exception {

		String template = extractTemplateString();

		// sanity check - misconfigured templates
		if (template == null) {
			throw new CayenneRuntimeException("No template string configured for adapter " + dbAdapter.getClass().getName());
		}

		boolean loggable = dataNode.getJdbcEventLogger().isLoggable();
		List<Number> counts = new ArrayList<>();

		// bind either positional or named parameters;
		// for legacy reasons named parameters are processed as a batch.. this
		// should go away after 4.0; newer positional parameter only support a
		// single set of values.
		if (query.getPositionalParams().isEmpty()) {
			runWithNamedParametersBatch(connection, callback, template, counts, loggable);
		} else {
			runWithPositionalParameters(connection, callback, template, counts, loggable);
		}

		// notify of combined counts of all queries inside SQLTemplate
		// multiplied by the number of parameter sets...
		int[] ints = new int[counts.size()];
		for (int i = 0; i < ints.length; i++) {
			ints[i] = counts.get(i).intValue();
		}

		callback.nextBatchCount(query, ints);
	}

	private void bindExtendedTypes(ParameterBinding[] bindings) {
		int i = 1;
		for (ParameterBinding binding : bindings) {
			Object value = binding.getValue();
			ExtendedType extendedType = value != null
					? getAdapter().getExtendedTypes().getRegisteredType(value.getClass())
					: getAdapter().getExtendedTypes().getDefaultType();
			binding.setExtendedType(extendedType);
			binding.setStatementPosition(i++);
		}
	}

	private void runWithPositionalParameters(Connection connection, OperationObserver callback, String template,
											 Collection<Number> counts, boolean loggable) throws Exception {

		SQLStatement compiled = dataNode.getSqlTemplateProcessor().processTemplate(template,
				query.getPositionalParams());

		bindExtendedTypes(compiled.getBindings());
		if (loggable) {
			dataNode.getJdbcEventLogger().logQuery(compiled.getSql(), compiled.getBindings());
		}

		execute(connection, callback, compiled, counts);
	}

	@SuppressWarnings("unchecked")
	private void runWithNamedParametersBatch(Connection connection, OperationObserver callback, String template,
											 Collection<Number> counts, boolean loggable) throws Exception {

		int size = query.parametersSize();

		// zero size indicates a one-shot query with no parameters
		// so fake a single entry batch...
		int batchSize = (size > 0) ? size : 1;

		// for now supporting deprecated batch parameters...
		Iterator<Map<String, ?>> it;
		if(size == 0) {
			Iterator empty = Collections.singleton(Collections.emptyMap()).iterator();
			it = empty;
		} else {
			it = query.parametersIterator();
		}

		for (int i = 0; i < batchSize; i++) {
			Map<String, ?> nextParameters = it.next();
			SQLStatement compiled = dataNode.getSqlTemplateProcessor().processTemplate(template, nextParameters);
			bindExtendedTypes(compiled.getBindings());
			if (loggable) {
				dataNode.getJdbcEventLogger().logQuery(compiled.getSql(), compiled.getBindings());
			}

			execute(connection, callback, compiled, counts);
		}

	}

	protected void execute(Connection connection, OperationObserver callback, SQLStatement compiled,
						   Collection<Number> updateCounts) throws SQLException, Exception {

		long t1 = System.currentTimeMillis();
		boolean iteratedResult = callback.isIteratedResult();
		int generatedKeys = query.isReturnGeneratedKeys() ? Statement.RETURN_GENERATED_KEYS : Statement.NO_GENERATED_KEYS;
		PreparedStatement statement = connection.prepareStatement(compiled.getSql(), generatedKeys);

		try {
			bind(statement, compiled.getBindings());

			// process a mix of results
			boolean isResultSet = statement.execute();

			if(query.isReturnGeneratedKeys()) {
				ResultSet generatedKeysResultSet = statement.getGeneratedKeys();
				if (generatedKeysResultSet != null) {
					processSelectResult(compiled, connection, statement, generatedKeysResultSet, callback, t1);
				}
			}

			boolean firstIteration = true;
			while (true) {
				if (firstIteration) {
					firstIteration = false;
				} else {
					isResultSet = statement.getMoreResults();
				}

				if (isResultSet) {

					ResultSet resultSet = statement.getResultSet();
					if (resultSet != null) {

						try {
							processSelectResult(compiled, connection, statement, resultSet, callback, t1);
						} finally {
							if (!iteratedResult) {
								resultSet.close();
							}
						}

						// ignore possible following update counts and bail early on iterated results
						if (iteratedResult) {
							break;
						}
					}
				} else {
					int updateCount = statement.getUpdateCount();
					if (updateCount == -1) {
						break;
					}

					updateCounts.add(updateCount);
					dataNode.getJdbcEventLogger().logUpdateCount(updateCount);
				}
			}
		} finally {
			if (!iteratedResult) {
				statement.close();
			}
		}
	}

	@SuppressWarnings({ "unchecked", "rawtypes" })
	protected void processSelectResult(SQLStatement compiled, Connection connection, Statement statement,
									   ResultSet resultSet, OperationObserver callback, final long startTime) throws Exception {

		boolean iteratedResult = callback.isIteratedResult();
		ExtendedTypeMap types = dataNode.getAdapter().getExtendedTypes();
		RowDescriptorBuilder builder = configureRowDescriptorBuilder(compiled, resultSet);
		recreateQueryMetadata(resultSet);
		RowReader<?> rowReader = dataNode.rowReader(builder.getDescriptor(types), queryMetadata);
		ResultIterator<?> it = new JDBCResultIterator<>(statement, resultSet, rowReader);

		if (iteratedResult) {

			it = new ConnectionAwareResultIterator(it, connection) {
				@Override
				protected void doClose() {
					dataNode.getJdbcEventLogger().logSelectCount(rowCounter, System.currentTimeMillis() - startTime);
					super.doClose();
				}
			};
		}

		it = new LimitResultIterator<>(it, getFetchOffset(), query.getFetchLimit());

		if (iteratedResult) {
			try {
				callback.nextRows(query, it);
			} catch (Exception ex) {
				it.close();
				throw ex;
			}
		} else {
			// note that we are not closing the iterator here, relying on caller
			// to close the underlying ResultSet on its own... this is a hack,
			// maybe a cleaner flow is due here.
			List<?> resultRows = it.allRows();

			dataNode.getJdbcEventLogger().logSelectCount(resultRows.size(), System.currentTimeMillis() - startTime);

			callback.nextRows(query, resultRows);
		}
	}

	private void recreateQueryMetadata(ResultSet resultSet) throws SQLException {
		if(query.isUseScalar() && queryMetadata.getResultSetMapping() != null && queryMetadata.getResultSetMapping().isEmpty()){
			for(int i = 0; i < resultSet.getMetaData().getColumnCount(); i++) {
				queryMetadata.getResultSetMapping().add(new DefaultScalarResultSegment(String.valueOf(i), i));
			}
		}
	}

	/**
	 * Creates column descriptors based on compiled statement and query metadata
	 */
	private ColumnDescriptor[] createColumnDescriptors(SQLStatement compiled) {
		// SQLTemplate #result columns take precedence over other ways to determine the type
		if (compiled.getResultColumns().length > 0) {
			if(query.getResultColumnsTypes() != null) {
				throw new CayenneRuntimeException("Caused by setting return types by directives and by parameters in query.");
			} else {
				return compiled.getResultColumns();
			}
		}

		// check explicitly set column types
		if(query.getResultColumnsTypes() == null) {
			return null;
		}

		int size = query.getResultColumnsTypes().size();
		ColumnDescriptor[] columnDescriptors = new ColumnDescriptor[size];
		for(int i = 0; i < size; i++) {
			ColumnDescriptor columnDescriptor = new ColumnDescriptor();
			columnDescriptor.setJavaClass(query.getResultColumnsTypes().get(i).getCanonicalName());
			columnDescriptors[i] = columnDescriptor;
		}
		return columnDescriptors;
	}

	/**
	 * @since 3.0
	 */
	protected RowDescriptorBuilder configureRowDescriptorBuilder(SQLStatement compiled, ResultSet resultSet)
			throws SQLException {
		RowDescriptorBuilder builder = new RowDescriptorBuilder()
				.setResultSet(resultSet)
				.setColumns(createColumnDescriptors(compiled))
				.validateDuplicateColumnNames();

		if(query.getResultColumnsTypes() != null) {
			builder.mergeColumnsWithRsMetadata();
		}

		ObjEntity entity = queryMetadata.getObjEntity();
		if (entity != null && isResultColumnTypesEmpty()) {
			// TODO: andrus 2008/03/28 support flattened attributes with aliases...
			for (ObjAttribute attribute : entity.getAttributes()) {
				String column = attribute.getDbAttributePath();
				if (column == null || column.indexOf('.') > 0) {
					continue;
				}
				builder.overrideColumnType(column, attribute.getType());
			}
		}

		// override numeric Java types based on JDBC defaults for DbAttributes, as Oracle
		// ResultSetMetadata is not very precise about NUMERIC distinctions...
		// (BigDecimal vs Long vs. Integer)
		if (dbEntity != null && isResultColumnTypesEmpty()) {
			for (DbAttribute attribute : dbEntity.getAttributes()) {
				if (!builder.isOverriden(attribute.getName()) && TypesMapping.isNumeric(attribute.getType())) {
					builder.overrideColumnType(attribute.getName(), TypesMapping.getJavaBySqlType(attribute));
				}
			}
		}

		switch (query.getColumnNamesCapitalization()) {
			case LOWER:
				builder.useLowercaseColumnNames();
				break;
			case UPPER:
				builder.useUppercaseColumnNames();
				break;
		}

		return builder;
	}

	private boolean isResultColumnTypesEmpty(){
		return query.getResultColumnsTypes() == null || query.getResultColumnsTypes().isEmpty();
	}

	/**
	 * Extracts a template string from a SQLTemplate query. Exists mainly for
	 * the benefit of subclasses that can customize returned template.
	 *
	 * @since 1.2
	 */
	protected String extractTemplateString() {
		String sql = query.getTemplate(dbAdapter.getClass().getName());

		// note that we MUST convert line breaks to spaces. On some databases (DB2)
		// queries with breaks simply won't run; the rest are affected by CAY-726.
		return Util.stripLineBreaks(sql, ' ');
	}

	/**
	 * Binds parameters to the PreparedStatement.
	 */
	protected void bind(PreparedStatement preparedStatement, ParameterBinding[] bindings)
			throws SQLException, Exception {
		// bind parameters
		for (ParameterBinding binding : bindings) {
			dataNode.getAdapter().bindParameter(preparedStatement, binding);
		}

		if (queryMetadata.getStatementFetchSize() != 0) {
			preparedStatement.setFetchSize(queryMetadata.getStatementFetchSize());
		}

		int queryTimeout = queryMetadata.getQueryTimeout();
		if(queryTimeout != QueryMetadata.QUERY_TIMEOUT_DEFAULT) {
			preparedStatement.setQueryTimeout(queryTimeout);
		}
	}

	/**
	 * Returns a SQLTemplate for this action.
	 */
	public SQLTemplate getQuery() {
		return query;
	}

	/**
	 * @since 3.0
	 */
	protected int getFetchOffset() {
		return query.getFetchOffset();
	}
}
