/*
 * Copyright (c) 2004, PostgreSQL Global Development Group
 * See the LICENSE file in the project root for more information.
 */

package org.postgresql.jdbc;

import org.postgresql.PGResultSetMetaData;
import org.postgresql.core.BaseConnection;
import org.postgresql.core.Field;
import org.postgresql.util.GT;
import org.postgresql.util.JdbcBlackHole;
import org.postgresql.util.LruCache;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;

import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;

public class PgResultSetMetaData implements ResultSetMetaData, PGResultSetMetaData {
  protected final BaseConnection connection;
  protected final Field[] fields;

  private boolean fieldInfoFetched;

  /*
   * Initialise for a result with a tuple set and a field descriptor set
   *
   * @param fields the array of field descriptors
   */
  public PgResultSetMetaData(BaseConnection connection, Field[] fields) {
    this.connection = connection;
    this.fields = fields;
    fieldInfoFetched = false;
  }

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

  /*
   * Is the column automatically numbered (and thus read-only) I believe that PostgreSQL does not
   * support this feature.
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return true if so
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isAutoIncrement(int column) throws SQLException {
    fetchFieldMetaData();
    Field field = getField(column);
    FieldMetadata metadata = field.getMetadata();
    return metadata != null && metadata.autoIncrement;
  }

  /*
   * Does a column's case matter? ASSUMPTION: Any field that is not obviously case insensitive is
   * assumed to be case sensitive
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return true if so
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isCaseSensitive(int column) throws SQLException {
    Field field = getField(column);
    return connection.getTypeInfo().isCaseSensitive(field.getOID());
  }

  /*
   * 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
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isSearchable(int column) throws SQLException {
    return true;
  }

  /*
   * Is the column a cash value? 6.1 introduced the cash/money type, which haven't been incorporated
   * as of 970414, so I just check the type name for both 'cash' and 'money'
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return true if its a cash column
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isCurrency(int column) throws SQLException {
    String type_name = getPGType(column);

    return type_name.equals("cash") || type_name.equals("money");
  }

  /*
   * Indicates the nullability of values in the designated column.
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return one of the columnNullable values
   *
   * @exception SQLException if a database access error occurs
   */
  public int isNullable(int column) throws SQLException {
    fetchFieldMetaData();
    Field field = getField(column);
    return field.getMetadata().nullable;
  }

  /*
   * Is the column a signed number? In PostgreSQL, all numbers are signed, so this is trivial.
   * However, strings are not signed (duh!)
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return true if so
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isSigned(int column) throws SQLException {
    Field field = getField(column);
    return connection.getTypeInfo().isSigned(field.getOID());
  }

  /*
   * 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
   *
   * @exception SQLException if a database access error occurs
   */
  public int getColumnDisplaySize(int column) throws SQLException {
    Field field = getField(column);
    return connection.getTypeInfo().getDisplaySize(field.getOID(), field.getMod());
  }

  /*
   * @param column the first column is 1, the second is 2, etc.
   *
   * @return the column label
   *
   * @exception SQLException if a database access error occurs
   */
  public String getColumnLabel(int column) throws SQLException {
    Field field = getField(column);
    return field.getColumnLabel();
  }

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

  public String getBaseColumnName(int column) throws SQLException {
    Field field = getField(column);
    if (field.getTableOid() == 0) {
      return "";
    }
    fetchFieldMetaData();
    return field.getMetadata().columnName;
  }

  /*
   * @param column the first column is 1, the second is 2...
   *
   * @return the Schema Name
   *
   * @exception SQLException if a database access error occurs
   */
  public String getSchemaName(int column) throws SQLException {
    return "";
  }

  private boolean populateFieldsWithCachedMetadata() {
    boolean allOk = true;
    LruCache<FieldMetadata.Key, FieldMetadata> metadata = connection.getFieldMetadataCache();
    for (Field field : fields) {
      if (field.getMetadata() != null) {
        // No need to update metadata
        continue;
      }

      final FieldMetadata fieldMetadata =
          metadata.get(new FieldMetadata.Key(field.getTableOid(), field.getPositionInTable()));
      if (fieldMetadata == null) {
        allOk = false;
      } else {
        field.setMetadata(fieldMetadata);
      }
    }
    return allOk;
  }

  private void fetchFieldMetaData() throws SQLException {
    if (fieldInfoFetched) {
      return;
    }

    if (populateFieldsWithCachedMetadata()) {
      fieldInfoFetched = true;
      return;
    }

    StringBuilder sql = new StringBuilder(
        "SELECT c.oid, a.attnum, a.attname, c.relname, n.nspname, "
            + "a.attnotnull OR (t.typtype = 'd' AND t.typnotnull), "
            + "pg_catalog.pg_get_expr(d.adbin, d.adrelid) LIKE '%nextval(%' "
            + "FROM pg_catalog.pg_class c "
            + "JOIN pg_catalog.pg_namespace n ON (c.relnamespace = n.oid) "
            + "JOIN pg_catalog.pg_attribute a ON (c.oid = a.attrelid) "
            + "JOIN pg_catalog.pg_type t ON (a.atttypid = t.oid) "
            + "LEFT JOIN pg_catalog.pg_attrdef d ON (d.adrelid = a.attrelid AND d.adnum = a.attnum) "
            + "JOIN (");

    // 7.4 servers don't support row IN operations (a,b) IN ((c,d),(e,f))
    // so we've got to fake that with a JOIN here.
    //
    boolean hasSourceInfo = false;
    for (Field field : fields) {
      if (field.getMetadata() != null) {
        continue;
      }

      if (hasSourceInfo) {
        sql.append(" UNION ALL ");
      }

      sql.append("SELECT ");
      sql.append(field.getTableOid());
      if (!hasSourceInfo) {
        sql.append(" AS oid ");
      }
      sql.append(", ");
      sql.append(field.getPositionInTable());
      if (!hasSourceInfo) {
        sql.append(" AS attnum");
      }

      if (!hasSourceInfo) {
        hasSourceInfo = true;
      }
    }
    sql.append(") vals ON (c.oid = vals.oid AND a.attnum = vals.attnum) ");

    if (!hasSourceInfo) {
      fieldInfoFetched = true;
      return;
    }

    Statement stmt = connection.createStatement();
    ResultSet rs = null;
    try {
      LruCache<FieldMetadata.Key, FieldMetadata> metadataCache = connection.getFieldMetadataCache();
      rs = stmt.executeQuery(sql.toString());
      while (rs.next()) {
        int table = (int) rs.getLong(1);
        int column = (int) rs.getLong(2);
        String columnName = rs.getString(3);
        String tableName = rs.getString(4);
        String schemaName = rs.getString(5);
        int nullable =
            rs.getBoolean(6) ? ResultSetMetaData.columnNoNulls : ResultSetMetaData.columnNullable;
        boolean autoIncrement = rs.getBoolean(7);
        FieldMetadata fieldMetadata =
            new FieldMetadata(columnName, tableName, schemaName, nullable, autoIncrement);
        FieldMetadata.Key key = new FieldMetadata.Key(table, column);
        metadataCache.put(key, fieldMetadata);
      }
    } finally {
      JdbcBlackHole.close(rs);
      JdbcBlackHole.close(stmt);
    }
    populateFieldsWithCachedMetadata();
  }

  public String getBaseSchemaName(int column) throws SQLException {
    fetchFieldMetaData();
    Field field = getField(column);
    return field.getMetadata().schemaName;
  }

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

  /*
   * 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
   *
   * @exception SQLException if a database access error occurs
   */
  public int getScale(int column) throws SQLException {
    Field field = getField(column);
    return connection.getTypeInfo().getScale(field.getOID(), field.getMod());
  }

  /*
   * @param column the first column is 1, the second is 2...
   *
   * @return column name, or "" if not applicable
   *
   * @exception SQLException if a database access error occurs
   *
   * @see #getBaseTableName
   */
  public String getTableName(int column) throws SQLException {
    return getBaseTableName(column);
  }

  public String getBaseTableName(int column) throws SQLException {
    fetchFieldMetaData();
    Field field = getField(column);
    return field.getMetadata().tableName;
  }

  /*
   * What's a column's table's catalog name? As with getSchemaName(), we can say that if
   * getTableName() returns n/a, then we can too - otherwise, we need to work on it.
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return catalog name, or "" if not applicable
   *
   * @exception SQLException if a database access error occurs
   */
  public String getCatalogName(int column) throws SQLException {
    return "";
  }

  /*
   * 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
   *
   * @exception SQLException if a database access error occurs
   *
   * @see org.postgresql.Field#getSQLType
   *
   * @see java.sql.Types
   */
  public int getColumnType(int column) throws SQLException {
    return getSQLType(column);
  }

  /*
   * Is a column Text or Binary?
   *
   * @param column the first column is 1, the second is 2...
   *
   * @return column name, or "" if not applicable
   *
   * @exception SQLException if a database access error occurs
   */
  public int getFormat(int column) throws SQLException {
    return getField(column).getFormat();
  }

  /*
   * 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
   *
   * @exception SQLException if a database access error occurs
   */
  public String getColumnTypeName(int column) throws SQLException {
    String type = getPGType(column);
    if (isAutoIncrement(column)) {
      if ("int4".equals(type)) {
        return "serial";
      } else if ("int8".equals(type)) {
        return "bigserial";
      }
    }

    return type;
  }

  /*
   * Is the column definitely not writable? In reality, we would have to check the GRANT/REVOKE
   * stuff for this to be effective, and I haven't really looked into that yet, so this will get
   * re-visited.
   *
   * @param column the first column is 1, the second is 2, etc.
   *
   * @return true if so
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isReadOnly(int column) throws SQLException {
    return false;
  }

  /*
   * Is it possible for a write on the column to succeed? Again, we would in reality have to check
   * the GRANT/REVOKE stuff, which I haven't worked with as yet. However, if it isn't ReadOnly, then
   * it is obviously writable.
   *
   * @param column the first column is 1, the second is 2, etc.
   *
   * @return true if so
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isWritable(int column) throws SQLException {
    return !isReadOnly(column);
  }

  /*
   * Will a write on this column definately succeed? Hmmm...this is a bad one, since the two
   * preceding functions have not been really defined. I cannot tell is the short answer. I thus
   * return isWritable() just to give us an idea.
   *
   * @param column the first column is 1, the second is 2, etc..
   *
   * @return true if so
   *
   * @exception SQLException if a database access error occurs
   */
  public boolean isDefinitelyWritable(int column) throws SQLException {
    return false;
  }

  // ********************************************************
  // END OF PUBLIC INTERFACE
  // ********************************************************

  /*
   * For several routines in this package, we need to convert a columnIndex into a Field[]
   * descriptor. Rather than do the same code several times, here it is.
   *
   * @param columnIndex the first column is 1, the second is 2...
   *
   * @return the Field description
   *
   * @exception SQLException if a database access error occurs
   */
  protected Field getField(int columnIndex) throws SQLException {
    if (columnIndex < 1 || columnIndex > fields.length) {
      throw new PSQLException(
          GT.tr("The column index is out of range: {0}, number of columns: {1}.",
              columnIndex, fields.length),
          PSQLState.INVALID_PARAMETER_VALUE);
    }
    return fields[columnIndex - 1];
  }

  protected String getPGType(int columnIndex) throws SQLException {
    return connection.getTypeInfo().getPGType(getField(columnIndex).getOID());
  }

  protected int getSQLType(int columnIndex) throws SQLException {
    return connection.getTypeInfo().getSQLType(getField(columnIndex).getOID());
  }


  // ** JDBC 2 Extensions **

  // This can hook into our PG_Object mechanism

  /**
   * Returns the fully-qualified name of the Java class whose instances are manufactured if the
   * method <code>ResultSet.getObject</code> is called to retrieve a value from the column.
   *
   * <code>ResultSet.getObject</code> may return a subclass of the class returned by this method.
   *
   * @param column the first column is 1, the second is 2, ...
   * @return the fully-qualified name of the class in the Java programming language that would be
   *         used by the method <code>ResultSet.getObject</code> to retrieve the value in the
   *         specified column. This is the class name used for custom mapping.
   * @throws SQLException if a database access error occurs
   */
  public String getColumnClassName(int column) throws SQLException {
    Field field = getField(column);
    String result = connection.getTypeInfo().getJavaClass(field.getOID());

    if (result != null) {
      return result;
    }

    int sqlType = getSQLType(column);
    switch (sqlType) {
      case Types.ARRAY:
        return ("java.sql.Array");
      default:
        String type = getPGType(column);
        if ("unknown".equals(type)) {
          return ("java.lang.String");
        }
        return ("java.lang.Object");
    }
  }

  public boolean isWrapperFor(Class<?> iface) throws SQLException {
    return iface.isAssignableFrom(getClass());
  }

  public <T> T unwrap(Class<T> iface) throws SQLException {
    if (iface.isAssignableFrom(getClass())) {
      return iface.cast(this);
    }
    throw new SQLException("Cannot unwrap to " + iface.getName());
  }
}
