/*
  Copyright (c) 2002, 2019, 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 FOSS 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

 */

package com.mysql.jdbc;

import java.sql.SQLException;
import java.sql.Types;

/**
 * A ResultSetMetaData object can be used to find out about the types and properties of the columns in a ResultSet
 */
public class ResultSetMetaData implements java.sql.ResultSetMetaData {
    private static int clampedGetLength(Field f) {
        long fieldLength = f.getLength();

        if (fieldLength > Integer.MAX_VALUE) {
            fieldLength = Integer.MAX_VALUE;
        }

        return (int) fieldLength;
    }

    /**
     * Checks if the SQL Type is a Decimal/Number Type
     * 
     * @param type
     *            SQL Type
     */
    private static final boolean isDecimalType(int type) {
        switch (type) {
            case Types.BIT:
            case Types.TINYINT:
            case Types.SMALLINT:
            case Types.INTEGER:
            case Types.BIGINT:
            case Types.FLOAT:
            case Types.REAL:
            case Types.DOUBLE:
            case Types.NUMERIC:
            case Types.DECIMAL:
                return true;
        }

        return false;
    }

    Field[] fields;
    boolean useOldAliasBehavior = false;
    boolean treatYearAsDate = true;

    private ExceptionInterceptor exceptionInterceptor;

    /**
     * Initialize for a result with a tuple set and a field descriptor set
     * 
     * @param fields
     *            the array of field descriptors
     */
    public ResultSetMetaData(Field[] fields, boolean useOldAliasBehavior, boolean treatYearAsDate, ExceptionInterceptor exceptionInterceptor) {
        this.fields = fields;
        this.useOldAliasBehavior = useOldAliasBehavior;
        this.treatYearAsDate = treatYearAsDate;
        this.exceptionInterceptor = exceptionInterceptor;
    }

    /**
     * What's a column's table's catalog name?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return catalog name, or "" if not applicable
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public String getCatalogName(int column) throws SQLException {
        Field f = getField(column);

        String database = f.getDatabaseName();

        return (database == null) ? "" : database;
    }

    /**
     * What's the Java character encoding name for the given column?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the Java character encoding name for the given column, or null if
     *         no Java character encoding maps to the MySQL character set for
     *         the given column.
     * 
     * @throws SQLException
     *             if an invalid column index is given.
     */
    public String getColumnCharacterEncoding(int column) throws SQLException {
        String mysqlName = getColumnCharacterSet(column);

        String javaName = null;

        if (mysqlName != null) {
            try {
                javaName = CharsetMapping.getJavaEncodingForMysqlCharset(mysqlName);
            } catch (RuntimeException ex) {
                SQLException sqlEx = SQLError.createSQLException(ex.toString(), SQLError.SQL_STATE_ILLEGAL_ARGUMENT, null);
                sqlEx.initCause(ex);
                throw sqlEx;
            }
        }

        return javaName;
    }

    /**
     * What's the MySQL character set name for the given column?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the MySQL character set name for the given column
     * 
     * @throws SQLException
     *             if an invalid column index is given.
     */
    public String getColumnCharacterSet(int column) throws SQLException {
        return getField(column).getEncoding();
    }

    // --------------------------JDBC 2.0-----------------------------------

    /**
     * JDBC 2.0
     * 
     * <p>
     * Return the fully qualified name of the Java class whose instances are manufactured if ResultSet.getObject() is called to retrieve a value from the
     * column. ResultSet.getObject() may return a subClass of the class returned by this method.
     * </p>
     * 
     * @param column
     *            the column number to retrieve information for
     * 
     * @return the fully qualified name of the Java class whose instances are
     *         manufactured if ResultSet.getObject() is called to retrieve a
     *         value from the column.
     * 
     * @throws SQLException
     *             if an error occurs
     */
    public String getColumnClassName(int column) throws SQLException {
        Field f = getField(column);

        return getClassNameForJavaType(f.getSQLType(), f.isUnsigned(), f.getMysqlType(), f.isBinary() || f.isBlob(), f.isOpaqueBinary(), this.treatYearAsDate);
    }

    /**
     * Whats the number of columns in the ResultSet?
     * 
     * @return the number
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public int getColumnCount() throws SQLException {
        return this.fields.length;
    }

    /**
     * What is the column's normal maximum width in characters?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the maximum width
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public int getColumnDisplaySize(int column) throws SQLException {
        Field f = getField(column);

        int lengthInBytes = clampedGetLength(f);

        return lengthInBytes / f.getMaxBytesPerCharacter();
    }

    /**
     * What is the suggested column title for use in printouts and displays?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the column label
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public String getColumnLabel(int column) throws SQLException {
        if (this.useOldAliasBehavior) {
            return getColumnName(column);
        }

        return getField(column).getColumnLabel();
    }

    /**
     * What's a column's name?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the column name
     * 
     * @throws SQLException
     *             if a databvase access error occurs
     */
    public String getColumnName(int column) throws SQLException {
        if (this.useOldAliasBehavior) {
            return getField(column).getName();
        }

        String name = getField(column).getNameNoAliases();

        if (name != null && name.length() == 0) {
            return getField(column).getName();
        }

        return name;
    }

    /**
     * What is a column's SQL Type? (java.sql.Type int)
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the java.sql.Type value
     * 
     * @throws SQLException
     *             if a database access error occurs
     * 
     * @see java.sql.Types
     */
    public int getColumnType(int column) throws SQLException {
        return getField(column).getSQLType();
    }

    /**
     * Whats is the column's data source specific type name?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return the type name
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public String getColumnTypeName(int column) throws java.sql.SQLException {
        Field field = getField(column);

        int mysqlType = field.getMysqlType();
        int jdbcType = field.getSQLType();

        switch (mysqlType) {
            case MysqlDefs.FIELD_TYPE_BIT:
                return "BIT";
            case MysqlDefs.FIELD_TYPE_DECIMAL:
            case MysqlDefs.FIELD_TYPE_NEW_DECIMAL:
                return field.isUnsigned() ? "DECIMAL UNSIGNED" : "DECIMAL";

            case MysqlDefs.FIELD_TYPE_TINY:
                return field.isUnsigned() ? "TINYINT UNSIGNED" : "TINYINT";

            case MysqlDefs.FIELD_TYPE_SHORT:
                return field.isUnsigned() ? "SMALLINT UNSIGNED" : "SMALLINT";

            case MysqlDefs.FIELD_TYPE_LONG:
                return field.isUnsigned() ? "INT UNSIGNED" : "INT";

            case MysqlDefs.FIELD_TYPE_FLOAT:
                return field.isUnsigned() ? "FLOAT UNSIGNED" : "FLOAT";

            case MysqlDefs.FIELD_TYPE_DOUBLE:
                return field.isUnsigned() ? "DOUBLE UNSIGNED" : "DOUBLE";

            case MysqlDefs.FIELD_TYPE_NULL:
                return "NULL";

            case MysqlDefs.FIELD_TYPE_TIMESTAMP:
                return "TIMESTAMP";

            case MysqlDefs.FIELD_TYPE_LONGLONG:
                return field.isUnsigned() ? "BIGINT UNSIGNED" : "BIGINT";

            case MysqlDefs.FIELD_TYPE_INT24:
                return field.isUnsigned() ? "MEDIUMINT UNSIGNED" : "MEDIUMINT";

            case MysqlDefs.FIELD_TYPE_DATE:
                return "DATE";

            case MysqlDefs.FIELD_TYPE_TIME:
                return "TIME";

            case MysqlDefs.FIELD_TYPE_DATETIME:
                return "DATETIME";

            case MysqlDefs.FIELD_TYPE_TINY_BLOB:
                return "TINYBLOB";

            case MysqlDefs.FIELD_TYPE_MEDIUM_BLOB:
                return "MEDIUMBLOB";

            case MysqlDefs.FIELD_TYPE_LONG_BLOB:
                return "LONGBLOB";

            case MysqlDefs.FIELD_TYPE_BLOB:
                if (getField(column).isBinary()) {
                    return "BLOB";
                }

                return "TEXT";

            case MysqlDefs.FIELD_TYPE_VARCHAR:
                return "VARCHAR";

            case MysqlDefs.FIELD_TYPE_VAR_STRING:
                if (jdbcType == Types.VARBINARY) {
                    return "VARBINARY";
                }

                return "VARCHAR";

            case MysqlDefs.FIELD_TYPE_STRING:
                if (jdbcType == Types.BINARY) {
                    return "BINARY";
                }

                return "CHAR";

            case MysqlDefs.FIELD_TYPE_ENUM:
                return "ENUM";

            case MysqlDefs.FIELD_TYPE_YEAR:
                return "YEAR";

            case MysqlDefs.FIELD_TYPE_SET:
                return "SET";

            case MysqlDefs.FIELD_TYPE_GEOMETRY:
                return "GEOMETRY";

            case MysqlDefs.FIELD_TYPE_JSON:
                return "JSON";

            default:
                return "UNKNOWN";
        }
    }

    /**
     * Returns the field instance for the given column index
     * 
     * @param columnIndex
     *            the column number to retrieve a field instance for
     * 
     * @return the field instance for the given column index
     * 
     * @throws SQLException
     *             if an error occurs
     */
    protected Field getField(int columnIndex) throws SQLException {
        if ((columnIndex < 1) || (columnIndex > this.fields.length)) {
            throw SQLError.createSQLException(Messages.getString("ResultSetMetaData.46"), SQLError.SQL_STATE_INVALID_COLUMN_NUMBER, this.exceptionInterceptor);
        }

        return this.fields[columnIndex - 1];
    }

    /**
     * What is a column's number of decimal digits.
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return the precision
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public int getPrecision(int column) throws SQLException {
        Field f = getField(column);

        // if (f.getMysqlType() == MysqlDefs.FIELD_TYPE_NEW_DECIMAL) {
        // return f.getLength();
        // }

        if (isDecimalType(f.getSQLType())) {
            if (f.getDecimals() > 0) {
                return clampedGetLength(f) - 1 + f.getPrecisionAdjustFactor();
            }

            return clampedGetLength(f) + f.getPrecisionAdjustFactor();
        }

        switch (f.getMysqlType()) {
            case MysqlDefs.FIELD_TYPE_TINY_BLOB:
            case MysqlDefs.FIELD_TYPE_BLOB:
            case MysqlDefs.FIELD_TYPE_MEDIUM_BLOB:
            case MysqlDefs.FIELD_TYPE_LONG_BLOB:
                return clampedGetLength(f); // this may change in the future for now, the server only returns FIELD_TYPE_BLOB for _all_ BLOB types, but varying
                                           // lengths indicating the _maximum_ size for each BLOB type.
            default:
                return clampedGetLength(f) / f.getMaxBytesPerCharacter();

        }
    }

    /**
     * What is a column's number of digits to the right of the decimal point?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return the scale
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public int getScale(int column) throws SQLException {
        Field f = getField(column);

        if (isDecimalType(f.getSQLType())) {
            return f.getDecimals();
        }

        return 0;
    }

    /**
     * What is a column's table's schema? This relies on us knowing the table
     * name. The JDBC specification allows us to return "" if this is not
     * applicable.
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return the Schema
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public String getSchemaName(int column) throws SQLException {
        return "";
    }

    /**
     * Whats a column's table's name?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return column name, or "" if not applicable
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public String getTableName(int column) throws SQLException {
        String res = this.useOldAliasBehavior ? getField(column).getTableName() : getField(column).getTableNameNoAliases();
        return res == null ? "" : res;
    }

    /**
     * Is the column automatically numbered (and thus read-only)
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return true if so
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isAutoIncrement(int column) throws SQLException {
        Field f = getField(column);

        return f.isAutoIncrement();
    }

    /**
     * Does a column's case matter?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return true if so
     * 
     * @throws java.sql.SQLException
     *             if a database access error occurs
     */
    public boolean isCaseSensitive(int column) throws java.sql.SQLException {
        Field field = getField(column);

        int sqlType = field.getSQLType();

        switch (sqlType) {
            case Types.BIT:
            case Types.TINYINT:
            case Types.SMALLINT:
            case Types.INTEGER:
            case Types.BIGINT:
            case Types.FLOAT:
            case Types.REAL:
            case Types.DOUBLE:
            case Types.DATE:
            case Types.TIME:
            case Types.TIMESTAMP:
                return false;

            case Types.CHAR:
            case Types.VARCHAR:
            case Types.LONGVARCHAR:

                if (field.isBinary()) {
                    return true;
                }

                String collationName = field.getCollation();

                return ((collationName != null) && !collationName.endsWith("_ci"));

            default:
                return true;
        }
    }

    /**
     * Is the column a cash value?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return true if its a cash column
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isCurrency(int column) throws SQLException {
        return false;
    }

    /**
     * Will a write on this column definately succeed?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc..
     * 
     * @return true if so
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isDefinitelyWritable(int column) throws SQLException {
        return isWritable(column);
    }

    /**
     * Can you put a NULL in this column?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return one of the columnNullable values
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public int isNullable(int column) throws SQLException {
        if (!getField(column).isNotNull()) {
            return java.sql.ResultSetMetaData.columnNullable;
        }

        return java.sql.ResultSetMetaData.columnNoNulls;
    }

    /**
     * Is the column definitely not writable?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return true if so
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isReadOnly(int column) throws SQLException {
        return getField(column).isReadOnly();
    }

    /**
     * Can the column be used in a WHERE clause? Basically for this, I split the
     * functions into two types: recognised types (which are always useable),
     * and OTHER types (which may or may not be useable). The OTHER types, for
     * now, I will assume they are useable. We should really query the catalog
     * to see if they are useable.
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return true if they can be used in a WHERE clause
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isSearchable(int column) throws SQLException {
        return true;
    }

    /**
     * Is the column a signed number?
     * 
     * @param column
     *            the first column is 1, the second is 2...
     * 
     * @return true if so
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isSigned(int column) throws SQLException {
        Field f = getField(column);
        int sqlType = f.getSQLType();

        switch (sqlType) {
            case Types.TINYINT:
            case Types.SMALLINT:
            case Types.INTEGER:
            case Types.BIGINT:
            case Types.FLOAT:
            case Types.REAL:
            case Types.DOUBLE:
            case Types.NUMERIC:
            case Types.DECIMAL:
                return !f.isUnsigned();

            case Types.DATE:
            case Types.TIME:
            case Types.TIMESTAMP:
                return false;

            default:
                return false;
        }
    }

    /**
     * Is it possible for a write on the column to succeed?
     * 
     * @param column
     *            the first column is 1, the second is 2, etc.
     * 
     * @return true if so
     * 
     * @throws SQLException
     *             if a database access error occurs
     */
    public boolean isWritable(int column) throws SQLException {
        return !isReadOnly(column);
    }

    /**
     * Returns a string representation of this object
     * 
     * @return ...
     */
    @Override
    public String toString() {
        StringBuilder toStringBuf = new StringBuilder();
        toStringBuf.append(super.toString());
        toStringBuf.append(" - Field level information: ");

        for (int i = 0; i < this.fields.length; i++) {
            toStringBuf.append("\n\t");
            toStringBuf.append(this.fields[i].toString());
        }

        return toStringBuf.toString();
    }

    static String getClassNameForJavaType(int javaType, boolean isUnsigned, int mysqlTypeIfKnown, boolean isBinaryOrBlob, boolean isOpaqueBinary,
            boolean treatYearAsDate) {
        switch (javaType) {
            case Types.BIT:
            case Types.BOOLEAN:
                return "java.lang.Boolean";

            case Types.TINYINT:

                if (isUnsigned) {
                    return "java.lang.Integer";
                }

                return "java.lang.Integer";

            case Types.SMALLINT:

                if (isUnsigned) {
                    return "java.lang.Integer";
                }

                return "java.lang.Integer";

            case Types.INTEGER:

                if (!isUnsigned || mysqlTypeIfKnown == MysqlDefs.FIELD_TYPE_INT24) {
                    return "java.lang.Integer";
                }

                return "java.lang.Long";

            case Types.BIGINT:

                if (!isUnsigned) {
                    return "java.lang.Long";
                }

                return "java.math.BigInteger";

            case Types.DECIMAL:
            case Types.NUMERIC:
                return "java.math.BigDecimal";

            case Types.REAL:
                return "java.lang.Float";

            case Types.FLOAT:
            case Types.DOUBLE:
                return "java.lang.Double";

            case Types.CHAR:
            case Types.VARCHAR:
            case Types.LONGVARCHAR:
                if (!isOpaqueBinary) {
                    return "java.lang.String";
                }

                return "[B";

            case Types.BINARY:
            case Types.VARBINARY:
            case Types.LONGVARBINARY:

                if (mysqlTypeIfKnown == MysqlDefs.FIELD_TYPE_GEOMETRY) {
                    return "[B";
                } else if (isBinaryOrBlob) {
                    return "[B";
                } else {
                    return "java.lang.String";
                }

            case Types.DATE:
                return (treatYearAsDate || mysqlTypeIfKnown != MysqlDefs.FIELD_TYPE_YEAR) ? "java.sql.Date" : "java.lang.Short";

            case Types.TIME:
                return "java.sql.Time";

            case Types.TIMESTAMP:
                return "java.sql.Timestamp";

            default:
                return "java.lang.Object";
        }
    }

    /**
     * @see java.sql.Wrapper#isWrapperFor(Class)
     */
    public boolean isWrapperFor(Class<?> iface) throws SQLException {
        // This works for classes that aren't actually wrapping anything
        return iface.isInstance(this);
    }

    /**
     * @see java.sql.Wrapper#unwrap(Class)
     */
    public <T> T unwrap(Class<T> iface) throws java.sql.SQLException {
        try {
            // This works for classes that aren't actually wrapping anything
            return iface.cast(this);
        } catch (ClassCastException cce) {
            throw SQLError.createSQLException("Unable to unwrap to " + iface.toString(), SQLError.SQL_STATE_ILLEGAL_ARGUMENT, this.exceptionInterceptor);
        }
    }
}
