/*
 * Copyright 2004-2015 the Seasar Foundation and the Others.
 *
 * Licensed 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.
 */
package org.seasar.extension.jdbc.gen.internal.meta;

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import javax.sql.DataSource;

import org.seasar.extension.jdbc.gen.dialect.GenDialect;
import org.seasar.extension.jdbc.gen.internal.exception.TableNotFoundRuntimeException;
import org.seasar.extension.jdbc.gen.meta.DbColumnMeta;
import org.seasar.extension.jdbc.gen.meta.DbForeignKeyMeta;
import org.seasar.extension.jdbc.gen.meta.DbTableMeta;
import org.seasar.extension.jdbc.gen.meta.DbTableMetaReader;
import org.seasar.extension.jdbc.gen.meta.DbUniqueKeyMeta;
import org.seasar.extension.jdbc.util.ConnectionUtil;
import org.seasar.extension.jdbc.util.DataSourceUtil;
import org.seasar.extension.jdbc.util.DatabaseMetaDataUtil;
import org.seasar.framework.exception.SQLRuntimeException;
import org.seasar.framework.log.Logger;
import org.seasar.framework.util.ArrayMap;
import org.seasar.framework.util.ResultSetUtil;

/**
 * {@code DbTableMetaReader}の実装クラスです。
 * 
 * @author taedium
 */
public class DbTableMetaReaderImpl implements DbTableMetaReader {

    /** ロガー */
    protected Logger logger = Logger.getLogger(DbTableMetaReaderImpl.class);

    /** データソース */
    protected DataSource dataSource;

    /** 方言 */
    protected GenDialect dialect;

    /** スキーマ名 */
    protected String schemaName;

    /** 読み取り対象とするテーブル名のパターン */
    protected Pattern tableNamePattern;

    /** 読み取り非対象とするテーブル名のパターン */
    protected Pattern ignoreTableNamePattern;

    /** コメントを読む場合{@code true} */
    protected boolean readComment;

    /**
     * インスタンスを構築します。
     * 
     * @param dataSource
     *            データソース
     * @param dialect
     *            方言
     * @param schemaName
     *            スキーマ名、デフォルトのスキーマ名を表す場合は{@code null}
     * @param tableNamePattern
     *            対象とするテーブル名の正規表現
     * @param ignoreTableNamePattern
     *            対象としないテーブル名の正規表現
     * @param readComment
     *            コメントを読む場合{@code true}
     */
    public DbTableMetaReaderImpl(DataSource dataSource, GenDialect dialect,
            String schemaName, String tableNamePattern,
            String ignoreTableNamePattern, boolean readComment) {
        if (dataSource == null) {
            throw new NullPointerException("dataSource");
        }
        if (dialect == null) {
            throw new NullPointerException("dialect");
        }
        if (tableNamePattern == null) {
            throw new NullPointerException("tableNamePattern");
        }
        if (ignoreTableNamePattern == null) {
            throw new NullPointerException("ignoreTableNamePattern");
        }
        this.dataSource = dataSource;
        this.dialect = dialect;
        this.schemaName = schemaName;
        this.tableNamePattern = Pattern.compile(tableNamePattern,
                Pattern.CASE_INSENSITIVE);
        this.ignoreTableNamePattern = Pattern.compile(ignoreTableNamePattern,
                Pattern.CASE_INSENSITIVE);
        this.readComment = readComment;
    }

    public List<DbTableMeta> read() {
        Connection con = DataSourceUtil.getConnection(dataSource);
        try {
            DatabaseMetaData metaData = ConnectionUtil.getMetaData(con);
            List<DbTableMeta> dbTableMetaList = getDbTableMetaList(metaData,
                    schemaName != null ? schemaName
                            : getDefaultSchemaName(metaData));
            if (dbTableMetaList.isEmpty()) {
                throw new TableNotFoundRuntimeException(dialect.getClass()
                        .getName(), schemaName, tableNamePattern.pattern(),
                        ignoreTableNamePattern.pattern());
            }
            for (DbTableMeta tableMeta : dbTableMetaList) {
                Set<String> primaryKeySet = getPrimaryKeySet(metaData,
                        tableMeta);
                doDbUniqueKeyMeta(metaData, tableMeta, primaryKeySet);
                doDbColumnMeta(metaData, tableMeta, primaryKeySet);
                doDbForeignKeyMeta(metaData, tableMeta);
            }
            if (readComment && !dialect.isJdbcCommentAvailable()) {
                readCommentFromDictinary(con, dbTableMetaList);
            }
            return dbTableMetaList;
        } finally {
            ConnectionUtil.close(con);
        }
    }

    /**
     * 一意キーメタデータを処理します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * @param primaryKeySet
     *            主キーのセット
     */
    protected void doDbUniqueKeyMeta(DatabaseMetaData metaData,
            DbTableMeta tableMeta, Set<String> primaryKeySet) {
        for (DbUniqueKeyMeta ukMeta : getDbUniqueKeyMetaList(metaData,
                tableMeta)) {
            if (primaryKeySet.size() == ukMeta.getColumnNameList().size()
                    && primaryKeySet.containsAll(ukMeta.getColumnNameList())) {
                ukMeta.setPrimaryKey(true);
            }
            tableMeta.addUniqueKeyMeta(ukMeta);
        }
    }

    /**
     * カラムメタデータを処理します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * @param primaryKeySet
     *            主キーのセット
     */
    protected void doDbColumnMeta(DatabaseMetaData metaData,
            DbTableMeta tableMeta, Set<String> primaryKeySet) {
        for (DbColumnMeta columnMeta : getDbColumnMetaList(metaData, tableMeta)) {
            if (primaryKeySet.contains(columnMeta.getName())) {
                columnMeta.setPrimaryKey(true);
                if (primaryKeySet.size() == 1) {
                    columnMeta.setAutoIncrement(isAutoIncrement(metaData,
                            tableMeta, columnMeta.getName()));
                }
            }
            for (DbUniqueKeyMeta ukMeta : tableMeta.getUniqueKeyMetaList()) {
                if (ukMeta.getColumnNameList().size() == 1) {
                    String ukColumnName = ukMeta.getColumnNameList().get(0);
                    if (columnMeta.getName().equals(ukColumnName)) {
                        columnMeta.setUnique(true);
                    }
                }
            }
            tableMeta.addColumnMeta(columnMeta);
        }
    }

    /**
     * 外部キーメタデータを処理します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     */
    protected void doDbForeignKeyMeta(DatabaseMetaData metaData,
            DbTableMeta tableMeta) {
        for (DbForeignKeyMeta fkMeta : getDbForeignKeyMetaList(metaData,
                tableMeta)) {
            for (DbUniqueKeyMeta ukMeta : tableMeta.getUniqueKeyMetaList()) {
                if (fkMeta.getForeignKeyColumnNameList().equals(
                        ukMeta.getColumnNameList())) {
                    fkMeta.setUnique(true);
                }
            }
            tableMeta.addForeignKeyMeta(fkMeta);
        }
    }

    /**
     * デフォルトのスキーマ名を返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @return デフォルトのスキーマ名
     */
    protected String getDefaultSchemaName(DatabaseMetaData metaData) {
        String userName = DatabaseMetaDataUtil.getUserName(metaData);
        return dialect.getDefaultSchemaName(userName);
    }

    /**
     * テーブルメタデータのリストを返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param schemaName
     *            スキーマ名
     * @return テーブルメタデータのリスト
     */
    protected List<DbTableMeta> getDbTableMetaList(DatabaseMetaData metaData,
            String schemaName) {
        List<DbTableMeta> result = new ArrayList<DbTableMeta>();
        try {
            ResultSet rs = metaData.getTables(null, schemaName, null,
                    new String[] { "TABLE" });
            try {
                while (rs.next()) {
                    DbTableMeta dbTableMeta = new DbTableMeta();
                    dbTableMeta.setCatalogName(rs.getString("TABLE_CAT"));
                    dbTableMeta.setSchemaName(rs.getString("TABLE_SCHEM"));
                    dbTableMeta.setName(rs.getString("TABLE_NAME"));
                    if (readComment) {
                        dbTableMeta.setComment(rs.getString("REMARKS"));
                    }
                    if (isTargetTable(dbTableMeta)) {
                        result.add(dbTableMeta);
                    }
                }
                return result;
            } finally {
                ResultSetUtil.close(rs);
            }
        } catch (SQLException e) {
            throw new SQLRuntimeException(e);
        }
    }

    /**
     * 読み取り対象のテーブルの場合{@code true}を返します。
     * 
     * @param dbTableMeta
     *            テーブルメタデータ
     * @return 読み取り対象のテーブルの場合{@code true}
     */
    protected boolean isTargetTable(DbTableMeta dbTableMeta) {
        String name = dbTableMeta.getName();
        if (!tableNamePattern.matcher(name).matches()) {
            return false;
        }
        if (ignoreTableNamePattern.matcher(name).matches()) {
            return false;
        }
        return true;
    }

    /**
     * カラムメタデータのリストを返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * 
     * @return カラムメタデータのリスト
     */
    protected List<DbColumnMeta> getDbColumnMetaList(DatabaseMetaData metaData,
            DbTableMeta tableMeta) {
        List<DbColumnMeta> result = new ArrayList<DbColumnMeta>();
        try {
            ResultSet rs = metaData.getColumns(tableMeta.getCatalogName(),
                    tableMeta.getSchemaName(), tableMeta.getName(), null);
            try {
                while (rs.next()) {
                    DbColumnMeta columnDesc = new DbColumnMeta();
                    columnDesc.setName(rs.getString("COLUMN_NAME"));
                    columnDesc.setSqlType(rs.getInt("DATA_TYPE"));
                    columnDesc.setTypeName(rs.getString("TYPE_NAME"));
                    columnDesc.setLength(rs.getInt("COLUMN_SIZE"));
                    columnDesc.setScale(rs.getInt("DECIMAL_DIGITS"));
                    columnDesc.setNullable(rs.getBoolean("NULLABLE"));
                    columnDesc.setDefaultValue(rs.getString("COLUMN_DEF"));
                    if (readComment) {
                        columnDesc.setComment(rs.getString("REMARKS"));
                    }
                    result.add(columnDesc);
                }
                return result;
            } finally {
                ResultSetUtil.close(rs);
            }
        } catch (SQLException ex) {
            throw new SQLRuntimeException(ex);
        }
    }

    /**
     * 主キーのセットを返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * @return 主キーのセット
     */
    protected Set<String> getPrimaryKeySet(DatabaseMetaData metaData,
            DbTableMeta tableMeta) {
        Set<String> result = new HashSet<String>();
        try {
            ResultSet rs = metaData.getPrimaryKeys(tableMeta.getCatalogName(),
                    tableMeta.getSchemaName(), tableMeta.getName());
            try {
                while (rs.next()) {
                    result.add(rs.getString("COLUMN_NAME"));
                }
            } finally {
                ResultSetUtil.close(rs);
            }
            return result;
        } catch (SQLException ex) {
            throw new SQLRuntimeException(ex);
        }
    }

    /**
     * 外部キーメタデータのリストを返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * @return 外部キーメタデータのリスト
     */
    protected List<DbForeignKeyMeta> getDbForeignKeyMetaList(
            DatabaseMetaData metaData, DbTableMeta tableMeta) {
        @SuppressWarnings("unchecked")
        Map<String, DbForeignKeyMeta> map = new ArrayMap();
        try {
            ResultSet rs = metaData.getImportedKeys(tableMeta.getCatalogName(),
                    tableMeta.getSchemaName(), tableMeta.getName());
            try {
                while (rs.next()) {
                    String name = rs.getString("FK_NAME");
                    if (!map.containsKey(name)) {
                        DbForeignKeyMeta fkMeta = new DbForeignKeyMeta();
                        fkMeta.setName(name);
                        fkMeta.setPrimaryKeyCatalogName(rs
                                .getString("PKTABLE_CAT"));
                        fkMeta.setPrimaryKeySchemaName(rs
                                .getString("PKTABLE_SCHEM"));
                        fkMeta.setPrimaryKeyTableName(rs
                                .getString("PKTABLE_NAME"));
                        map.put(name, fkMeta);
                    }
                    DbForeignKeyMeta fkMeta = map.get(name);
                    fkMeta.addPrimaryKeyColumnName(rs
                            .getString("PKCOLUMN_NAME"));
                    fkMeta.addForeignKeyColumnName(rs
                            .getString("FKCOLUMN_NAME"));
                }
            } finally {
                ResultSetUtil.close(rs);
            }
            DbForeignKeyMeta[] array = map.values().toArray(
                    new DbForeignKeyMeta[map.size()]);
            return Arrays.asList(array);
        } catch (SQLException ex) {
            throw new SQLRuntimeException(ex);
        }
    }

    /**
     * 一意キーメタデータのリストを返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * @return 一意キーメタデータのリスト
     */
    protected List<DbUniqueKeyMeta> getDbUniqueKeyMetaList(
            DatabaseMetaData metaData, DbTableMeta tableMeta) {

        if (!dialect.supportsGetIndexInfo(tableMeta.getCatalogName(), tableMeta
                .getSchemaName(), tableMeta.getName())) {
            logger.log("WS2JDBCGen0002", new Object[] {
                    tableMeta.getCatalogName(), tableMeta.getSchemaName(),
                    tableMeta.getName() });
            return Collections.emptyList();
        }

        @SuppressWarnings("unchecked")
        Map<String, DbUniqueKeyMeta> map = new ArrayMap();
        try {
            ResultSet rs = metaData
                    .getIndexInfo(tableMeta.getCatalogName(), tableMeta
                            .getSchemaName(), tableMeta.getName(), true, false);
            try {
                while (rs.next()) {
                    String name = rs.getString("INDEX_NAME");
                    if (!map.containsKey(name)) {
                        DbUniqueKeyMeta ukMeta = new DbUniqueKeyMeta();
                        ukMeta.setName(name);
                        map.put(name, ukMeta);
                    }
                    DbUniqueKeyMeta ukMeta = map.get(name);
                    ukMeta.addColumnName(rs.getString("COLUMN_NAME"));
                }
            } finally {
                ResultSetUtil.close(rs);
            }
            DbUniqueKeyMeta[] array = map.values().toArray(
                    new DbUniqueKeyMeta[map.size()]);
            return Arrays.asList(array);
        } catch (SQLException ex) {
            throw new SQLRuntimeException(ex);
        }
    }

    /**
     * 列の値が自動的に増分される場合{@code true}を返します。
     * 
     * @param metaData
     *            データベースメタデータ
     * @param tableMeta
     *            テーブルメタデータ
     * @param columnName
     *            カラム名
     * @return 列が自動的に増分される場合{@code true}、そうでない場合{@code false}
     */
    protected boolean isAutoIncrement(DatabaseMetaData metaData,
            DbTableMeta tableMeta, String columnName) {
        try {
            return dialect.isAutoIncrement(metaData.getConnection(), tableMeta
                    .getCatalogName(), tableMeta.getSchemaName(), tableMeta
                    .getName(), columnName);
        } catch (SQLException ex) {
            throw new SQLRuntimeException(ex);
        }
    }

    /**
     * コメントをデータベースのディクショナリから直接取得します。
     * 
     * @param connection
     *            コネクション
     * @param dbTableMetaList
     *            テーブルメタデータのリスト
     */
    protected void readCommentFromDictinary(Connection connection,
            List<DbTableMeta> dbTableMetaList) {
        try {
            for (DbTableMeta tableMeta : dbTableMetaList) {
                String tableComment = dialect.getTableComment(connection,
                        tableMeta.getCatalogName(), tableMeta.getSchemaName(),
                        tableMeta.getName());
                tableMeta.setComment(tableComment);
                Map<String, String> columnCommentMap = dialect
                        .getColumnCommentMap(connection, tableMeta
                                .getCatalogName(), tableMeta.getSchemaName(),
                                tableMeta.getName());
                for (DbColumnMeta columnMeta : tableMeta.getColumnMetaList()) {
                    String columnName = columnMeta.getName();
                    if (columnCommentMap.containsKey(columnName)) {
                        String columnComment = columnCommentMap.get(columnName);
                        columnMeta.setComment(columnComment);
                    }
                }
            }
        } catch (SQLException e) {
            throw new SQLRuntimeException(e);
        }
    }
}
