/*
 * 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
 *
 *     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.
 *
 * $Header:$
 */

package org.apache.beehive.controls.system.jdbc.parser;

import org.apache.beehive.controls.api.ControlException;
import org.apache.beehive.controls.api.context.ControlBeanContext;
import org.apache.beehive.controls.system.jdbc.JdbcControl;
import org.apache.beehive.controls.system.jdbc.TypeMappingsFactory;

import javax.sql.RowSet;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.util.Calendar;

/**
 * Represents a fully parsed SQL statement. SqlStatements can be used to generated a java.sql.PreparedStatement.
 */
public final class SqlStatement extends SqlFragmentContainer implements Serializable {

    private static final TypeMappingsFactory _tmf = TypeMappingsFactory.getInstance();
    private boolean _callableStatement = false;
    private boolean _cacheableStatement = true;

    //
    // set from SQL annotation element values
    //
    private boolean _batchUpdate;
    private boolean _getGeneratedKeys;
    private String[] _genKeyColumnNames;
    private int _fetchSize;
    private int _maxArray;
    private int _maxRows;
    private int[] _genKeyColumnIndexes;
    private JdbcControl.ScrollType _scrollType;
    private JdbcControl.FetchDirection _fetchDirection;
    private JdbcControl.HoldabilityType _holdability;

    /**
     * Create a new SqlStatement.
     */
    SqlStatement() {
        super();
    }

    /**
     * Append a SqlFragment to the end of this statement.
     *
     * @param frag SqlFragment to append.
     */
    void addChild(SqlFragment frag) {
        super.addChild(frag);

        if (frag.isDynamicFragment()) {
            _cacheableStatement = false;
        }
    }


    /**
     * Can the PreparedStatement generated by this class be cached?
     *
     * @return true if this statement can be cached by the SqlParser.
     */
    boolean isCacheable() { return _cacheableStatement; }


    /**
     * Does this statement generate a callable or prepared statement?
     *
     * @return true if this statement generates callable statement.
     */
    public boolean isCallableStatement() { return _callableStatement; }

    /**
     * Does this statement do a batch update?
     *
     * @return true if this statement should be executed as a batch update.
     */
    public boolean isBatchUpdate() { return _batchUpdate; }

    /**
     * Does this statement return generatedKeys?
     *
     * @return true if getGeneratedKeys set to true.
     */
    public boolean getsGeneratedKeys() { return _getGeneratedKeys; }

    /**
     * Generates the PreparedStatement the SQL statement.
     *
     * @param context    ControlBeanContext instance.
     * @param connection Connection to database.
     * @param calendar   Calendar instance which can be used to resolve date/time values.
     * @param method     Method the SQL is associated with.
     * @param arguments  Method parameters.
     * @return The PreparedStatement generated by this statement.
     * @throws SQLException If PreparedStatement cannot be created.
     */
    public PreparedStatement createPreparedStatement(ControlBeanContext context, Connection connection,
                                                     Calendar calendar, Method method, Object[] arguments)
            throws SQLException {

        PreparedStatement preparedStatement = null;
        loadSQLAnnotationStatmentOptions(context, method);
        checkJdbcSupport(connection.getMetaData());

        _callableStatement = setCallableStatement(arguments);

        try {
            final String sql = getPreparedStatementText(context, method, arguments);

            //
            // is this a request for generatedKeys ?
            //
            if (_getGeneratedKeys) {

                if (_callableStatement) {
                    throw new ControlException("getGeneratedKeys not supported for CallableStatements");
                }

                if (_genKeyColumnNames.length > 0) {
                    preparedStatement = connection.prepareStatement(sql, _genKeyColumnNames);
                } else if (_genKeyColumnIndexes.length > 0) {
                    preparedStatement = connection.prepareStatement(sql, _genKeyColumnIndexes);
                } else {
                    preparedStatement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
                }

            } else {

                if (_holdability == JdbcControl.HoldabilityType.DRIVER_DEFAULT) {
                    if (_scrollType == JdbcControl.ScrollType.DRIVER_DEFAULT) {
                        preparedStatement = (_callableStatement) ? connection.prepareCall(sql) : connection.prepareStatement(sql);
                    } else {
                        preparedStatement = (_callableStatement)
                                ? connection.prepareCall(sql, _scrollType.getType(), _scrollType.getConcurrencyType())
                                : connection.prepareStatement(sql, _scrollType.getType(), _scrollType.getConcurrencyType());
                    }
                } else {
                    preparedStatement = (_callableStatement)
                            ? connection.prepareCall(sql, _scrollType.getType(), _scrollType.getConcurrencyType(), _holdability.getHoldability())
                            : connection.prepareStatement(sql, _scrollType.getType(), _scrollType.getConcurrencyType(), _holdability.getHoldability());
                }
            }

            //
            // If the method argument is of type SQLParameter, treat this statement as a CallableStatement,
            //
            if (_callableStatement) {
                for (SqlFragment sf : _children) {
                    if (sf.hasParamValue()) {
                        throw new ControlException("Cannot use parameter substution and SQLParameter array in the same method.");
                    }
                }
                JdbcControl.SQLParameter[] params = (JdbcControl.SQLParameter[]) arguments[0];
                if (params == null) {
                    return preparedStatement;
                }
                for (int i = 0; i < params.length; i++) {
                    JdbcControl.SQLParameter p = params[i];
                    if (p.dir != JdbcControl.SQLParameter.OUT) {
                        Object value = params[i].value;
                        setPreparedStatementParameter(preparedStatement, i + 1, value, params[i].type, calendar);
                    }

                    if (p.dir != JdbcControl.SQLParameter.IN) {
                        ((CallableStatement) preparedStatement).registerOutParameter(i + 1, params[i].type);
                    }
                }


                //
                // special handling for batch updates
                //
            } else if (_batchUpdate) {
                doBatchUpdate(preparedStatement, arguments, calendar);


                //
                // standard case, not a batch or callable
                //
            } else {
                int pIndex = 1;
                for (SqlFragment sf : _children) {
                    if (sf.hasParamValue()) {
                        Object values[] = sf.getParameterValues(context, method, arguments);
                        for (Object value : values) {
                            setPreparedStatementParameter(preparedStatement, pIndex++, value, sf.getParamSqlDataType(), calendar);
                        }
                    }
                }
            }
        } catch (SQLException e) {
            if (preparedStatement != null) preparedStatement.close();
            throw e;
        }

        preparedStatement.setFetchDirection(_fetchDirection.getDirection());
        preparedStatement.setFetchSize(_fetchSize);
        preparedStatement.setMaxRows(computeMaxRows(method));

        return preparedStatement;
    }

    /**
     * Generates the PreparedStatement the SQL statement.
     *
     * @param context    ControlBeanContext instance.
     * @param connection Connection to database.
     * @param method     Method the SQL is associated with.
     * @param arguments  Method parameters.
     * @return The PreparedStatement generated by this statement.
     */
    public String createPreparedStatementString(ControlBeanContext context, Connection connection,
                                                Method method, Object[] arguments) {

        final boolean callableStatement = setCallableStatement(arguments);
        StringBuilder sqlString = new StringBuilder(getPreparedStatementText(context, method, arguments));

        if (callableStatement) {
            JdbcControl.SQLParameter[] params = (JdbcControl.SQLParameter[]) arguments[0];
            if (params == null) {
                return sqlString.toString();
            }

            sqlString.append(" Params: {");
            for (int i = 0; i < params.length; i++) {
                if (i > 0) { sqlString.append(params[i].value.toString()); }
            }
            sqlString.append("}");

        } else if (_batchUpdate) {
            sqlString.append(" Params: batch update.");

        } else {
            sqlString.append(" Params: {");
            boolean first = true;
            for (SqlFragment sf : _children) {
                if (sf.hasParamValue()) {
                    Object values[] = sf.getParameterValues(context, method, arguments);
                    for (Object value : values) {

                        if (!first) sqlString.append(", "); else first = false;
                        sqlString.append(value);
                    }
                }
            }
            sqlString.append("}");
        }
        return sqlString.toString();
    }


    // /////////////////////////////////////////////////// PRIVATE METHODS ///////////////////////////////////////////

    /**
     * Sets the specified parameter in the prepared statement.
     *
     * @param ps      A PreparedStatement.
     * @param i       index of parameter to set.
     * @param value   value of the parameter.
     * @param sqlType SQL type of value.
     * @param cal     A calendar instance used to resolve date/time values.
     * @throws SQLException If the parameter cannot be set.
     */
    private void setPreparedStatementParameter(PreparedStatement ps, int i, Object value, int sqlType, Calendar cal)
            throws SQLException {

        if (sqlType == Types.NULL) {
            sqlType = _tmf.getSqlType(value);
        }

        if (value == null) {
            ps.setNull(i, Types.NULL == sqlType ? Types.VARCHAR : sqlType);
            return;
        }

        switch (sqlType) {

            case Types.VARCHAR:
                if (!(value instanceof String)) value = value.toString();
                break;

            case Types.BOOLEAN:
                if (value instanceof Boolean) {
                    ps.setBoolean(i, ((Boolean) value).booleanValue());
                    return;
                }
                break;

            case Types.TIMESTAMP:
                if (value instanceof java.util.Calendar) {
                    Calendar calValue = (Calendar) value;

// @todo: validate it is correct to comment out call to deprectated method
//                    if (cal == null) {
//                        /* NOTE: drivers are inconsistent in their handling of setTimestamp(i,date,cal)
//                         * so we won't use that, unless the user calls setCalendar().
//                         * I'm going with the theory that it makes sense to store
//                         * the time relative to the Calendar's timezone rather than
//                         * the system timezone otherwise, using a Calendar would be a no-op.
//                         */
//                        value = new java._sql.Timestamp(calValue.get(Calendar.YEAR) - 1900,
//                                                       calValue.get(Calendar.MONTH),
//                                                       calValue.get(Calendar.DATE),
//                                                       calValue.get(Calendar.HOUR_OF_DAY),
//                                                       calValue.get(Calendar.MINUTE),
//                                                       calValue.get(Calendar.SECOND),
//                                                       calValue.get(Calendar.MILLISECOND));
//                    } else {
                    value = new java.sql.Timestamp(calValue.getTimeInMillis());
//                    }
                } else if (java.util.Date.class.equals(value.getClass())) {
                    // some drivers don't like java.util.Date
                    value = new java.sql.Timestamp(((java.util.Date) value).getTime());
                }

                if (value instanceof java.sql.Timestamp) {
                    if (cal == null)
                        ps.setTimestamp(i, (java.sql.Timestamp) value);
                    else
                        ps.setTimestamp(i, (java.sql.Timestamp) value, cal);
                    return;
                }
                break;

            case Types.DATE:
                if (value instanceof java.util.Calendar) {
                    /* NOTE: see note above
                     Calendar cal = (Calendar)value;
                     value = new java._sql.Date(cal.getTimeInMillis());
                     ps.setDate(i, (java._sql.Date)value, cal);
                     return;
                     */
                    Calendar calValue = (Calendar) value;

                    // @todo: validate that commenting out deprected method is correct behavior
//                    if (cal == null) {
//                        value = new java._sql.Date(calValue.get(Calendar.YEAR - 1900),
//                                                  calValue.get(Calendar.MONTH),
//                                                  calValue.get(Calendar.DATE));
//                    } else {
                    value = new java.sql.Date(calValue.getTimeInMillis());
//                    }
                } else if (value.getClass() == java.util.Date.class) {
                    // some drivers don't like java.util.Date
                    value = new java.sql.Date(((java.util.Date) value).getTime());
                }

                if (value instanceof java.sql.Date) {
                    if (cal == null) {
                        ps.setDate(i, (java.sql.Date) value);
                    } else {
                        ps.setDate(i, (java.sql.Date) value, cal);
                    }
                    return;
                }
                break;

            case Types.TIME:
                if (value instanceof java.sql.Time) {
                    if (cal == null) {
                        ps.setTime(i, (java.sql.Time) value);
                    } else {
                        ps.setTime(i, (java.sql.Time) value, cal);
                    }
                    return;
                }
                break;
        }

        if (sqlType == Types.NULL) {
            ps.setObject(i, value);
        } else {
            ps.setObject(i, value, sqlType);
        }
    }

    /**
     * Determine if this SQL will generate a callable or prepared statement.
     *
     * @param args The method's argument list which this SQL annotation was assocatied with.
     * @return true if this statement will generated a CallableStatement
     */
    private boolean setCallableStatement(Object[] args) {

        // CallableStatement vs. PreparedStatement
        if (args != null && args.length == 1 && args[0] != null) {
            Class argClass = args[0].getClass();
            if (argClass.isArray() && JdbcControl.SQLParameter.class.isAssignableFrom(argClass.getComponentType())) {
                return true;
            }
        }
        return false;
    }

    /**
     * Build a prepared statement for a batch update.
     *
     * @param ps   The PreparedStatement object.
     * @param args The parameter list of the jdbccontrol method.
     * @param cal  A Calendar instance used to resolve date/time values.
     * @throws SQLException If a batch update cannot be performed.
     */
    private void doBatchUpdate(PreparedStatement ps, Object[] args, Calendar cal) throws SQLException {

        final int[] sqlTypes = new int[args.length];
        final Object[] objArrays = new Object[args.length];

        // build an array of type values and object arrays
        for (int i = 0; i < args.length; i++) {
            sqlTypes[i] = _tmf.getSqlType(args[i].getClass().getComponentType());
            objArrays[i] = TypeMappingsFactory.toObjectArray(args[i]);
        }

        final int rowCount = ((Object[]) objArrays[0]).length;
        for (int i = 0; i < rowCount; i++) {
            for (int j = 0; j < args.length; j++) {
                setPreparedStatementParameter(ps, j + 1, ((Object[]) objArrays[j])[i], sqlTypes[j], cal);
            }
            ps.addBatch();
        }
    }

    /**
     * Load element values from the SQL annotation which apply to Statements.
     *
     * @param context ControlBeanContext instance.
     * @param method  Annotated method.
     */
    private void loadSQLAnnotationStatmentOptions(ControlBeanContext context, Method method) {

        final JdbcControl.SQL methodSQL = (JdbcControl.SQL) context.getMethodPropertySet(method, JdbcControl.SQL.class);

        _batchUpdate = methodSQL.batchUpdate();
        _getGeneratedKeys = methodSQL.getGeneratedKeys();
        _genKeyColumnNames = methodSQL.generatedKeyColumnNames();
        _genKeyColumnIndexes = methodSQL.generatedKeyColumnIndexes();
        _scrollType = methodSQL.scrollableResultSet();
        _fetchDirection = methodSQL.fetchDirection();
        _fetchSize = methodSQL.fetchSize();
        _maxRows = methodSQL.maxRows();
        _maxArray = methodSQL.arrayMaxLength();

        _holdability = methodSQL.resultSetHoldabilityOverride();
    }

    /**
     * Checks that all statement options specified in annotation are supported by the database.
     *
     * @param metaData
     * @throws SQLException
     */
    private void checkJdbcSupport(DatabaseMetaData metaData) throws SQLException {

        if (_getGeneratedKeys && !metaData.supportsGetGeneratedKeys()) {
            throw new ControlException("The database does not support getGeneratedKeys.");
        }

        if (_batchUpdate && !metaData.supportsBatchUpdates()) {
            throw new ControlException("The database does not support batchUpdates.");
        }

        if (_scrollType != JdbcControl.ScrollType.DRIVER_DEFAULT
                && !metaData.supportsResultSetConcurrency(_scrollType.getType(), _scrollType.getConcurrencyType())) {
            throw new ControlException("The database does not support the ResultSet concurrecy type: " + _scrollType.toString());
        }

        if (_holdability != JdbcControl.HoldabilityType.DRIVER_DEFAULT
                && !metaData.supportsResultSetHoldability(_holdability.getHoldability())) {
            throw new ControlException("The database does not support the ResultSet holdability type: " + _holdability.toString());
        }
    }

    /**
     * The much maligned method for computing the maximum number of ResultSet rows this statement should return.
     * The values of maxRows and arrayMaxLength are enforced at compile-time by the JdbcControlChecker to be the
     * following: MAXROWS_ALL <= maxRows, 0 < arrayMaxLength
     *
     * @param method The annotated method.
     * @return max number of resultSet rows to return from the query.
     */
    private int computeMaxRows(Method method) {

        Class returnType = method.getReturnType();

        final boolean isArray = returnType.isArray();
        final boolean isRowSet = returnType.equals(RowSet.class);

        int maxSet = _maxRows;
        if (isArray && _maxArray != JdbcControl.MAXROWS_ALL) {
            maxSet = _maxRows == JdbcControl.MAXROWS_ALL ? _maxArray + 1 : Math.min(_maxArray + 1, _maxRows);
        } else if (isRowSet && _maxRows > 0) {
            maxSet = _maxRows + 1;
        }

        return maxSet;
    }
}
