/*
 * 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.
 */
package org.apache.empire.db.codegen;

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Types;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.List;

import org.apache.empire.commons.StringUtils;
import org.apache.empire.data.DataType;
import org.apache.empire.db.DBColumn;
import org.apache.empire.db.DBCommandExpr;
import org.apache.empire.db.DBDatabase;
import org.apache.empire.db.DBRelation;
import org.apache.empire.db.DBTable;
import org.apache.empire.db.DBTableColumn;
import org.apache.empire.db.DBView;
import org.apache.empire.db.DBView.DBViewColumn;
import org.apache.empire.db.codegen.util.DBUtil;
import org.apache.empire.exceptions.ItemNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class is used to create a in memory DBDatabase of a given SQLConnection
 * and Configuration
 * 
 * @author Benjamin Venditti
 */
public class CodeGenParser {

	public static class InMemoryDatabase extends DBDatabase {
        private static final long serialVersionUID = 1L;
        
        @Override
        public List<DBRelation> getRelations()
        {
            return relations;
        }
	}
	
	public static class InMemoryView extends DBView {
    private final static long serialVersionUID = 1L;

		public InMemoryView(String name, DBDatabase db) {
			super(name, db);
		}
		
		public DBViewColumn addCol(String columnName,DataType dataType)
		{
			return addColumn(columnName, dataType);
		}
		
		@Override
		public DBCommandExpr createCommand() {
			return null;
		}
	}

	private static final Logger log = LoggerFactory.getLogger(CodeGenParser.class);
	
	private DatabaseMetaData dbMeta;
	private Connection con;
	private CodeGenConfig config;

	/**
	 * create a empty in memory Database and populates it
	 */
	public CodeGenParser(CodeGenConfig config) {
	    this.config = config;
	}

	/**
	 * returns the populated DBDatabase
	 */
	public DBDatabase loadDbModel() {
		DBDatabase db = new InMemoryDatabase();
	    try {           
            con = openJDBCConnection(config);
            populateDatabase(db);
        } 
        catch (SQLException e) 
        {
            throw new RuntimeException("Unable to read database metadata: " + e.getMessage(), e);
        }
        finally 
        {
            DBUtil.close(con, log);
        }
        return db;
	}

	/**
     * Opens and returns a JDBC-Connection.
     * JDBC url, user and password for the connection are obained from the SampleConfig bean
     * Please use the config.xml file to change connection params.
     */
	protected Connection openJDBCConnection(CodeGenConfig config) throws SQLException{
        log.info("Connecting to Database'" + config.getJdbcURL() + "' / User=" + config.getJdbcUser());
        Connection conn = null;
        try {
            Class.forName(config.getJdbcClass()).newInstance();
        }catch(Exception ex){
        	throw new SQLException("Could not load database driver: " + config.getJdbcClass());
        }
        conn = DriverManager.getConnection(config.getJdbcURL(), config.getJdbcUser(), config.getJdbcPwd());
        log.info("Connected successfully");
        return conn;
    }

	/**
	 * Returns whether to add the table or ignore it
	 * @param tableName
	 */
    protected boolean isPopulateTable(String tableName)
    {
        if (tableName.indexOf('$') >= 0)
            return false;
        return true;
    }
    
    /**
     * Returns whether to add the column or ignore it
     * @param columnName
     */
    protected boolean isPopulateColumn(String columnName)
    {
        return true;
    }
	
	/**
	 * Queries the metadata of the database for tables and vies and populates the
	 * database with those
	 * @throws SQLException 
	 */
    protected void populateDatabase(DBDatabase db) throws SQLException {
		ResultSet tables = null;
		ArrayList<String> populatedTables=new ArrayList<String>();
		try{
            this.dbMeta = con.getMetaData();
            String[] tablePatterns = {null}; // Could be null, so start that way.
			if(config.getDbTablePattern() != null)
				tablePatterns = config.getDbTablePattern().split(","); // Support a comma separated list of table patterns (i.e. specify a list of table names in the config file).
            
            int tableCount = 0; // Moved to be outside table pattern loop.
            int viewCount = 0;
            for(String pattern : tablePatterns){
                // types
                String[] types = config.isGenerateViews() ? new String[] { "TABLE", "VIEW" }
                                                          : new String[] { "TABLE" }; 
			    // Get table metadata
	            tables = dbMeta.getTables(
			            config.getDbCatalog(), 
			            config.getDbSchema(), 
			            pattern == null ? pattern: pattern.trim(),
						types);
	            
	            // Add all tables and views 
				while (tables.next()) {
					String tableName = tables.getString("TABLE_NAME");
			        if (!isPopulateTable(tableName)) {
                        log.info("Ignoring table " + tableName);
			            continue;
			        }
					// show
					String tableType = tables.getString("TABLE_TYPE");
					String tableSchema = tables.getString("TABLE_SCHEM");
					String templ = StringUtils.isNotEmpty(tableSchema) ? "{0}: {1} ({2})" : "{0}: {1}"; 
				    log.info(MessageFormat.format(templ, tableType, tableName, tableSchema));
					// Table or View
					if(tableType.equalsIgnoreCase("VIEW")){
						InMemoryView view = new InMemoryView(tableName, db);
						populateView(view);
						viewCount++;
					} else {
						DBTable table = new DBTable(tableName, db);
						populateTable(table);
						populatedTables.add(tableName);
						tableCount++;
					}
				}
			}
			// Add all relations
			gatherRelations(db, dbMeta, populatedTables);

			if (tableCount==0 && viewCount==0) {
			    // getTables returned no result
			    String info = "catalog="+config.getDbCatalog(); 
                info += "/ schema="+config.getDbSchema(); 
                info += "/ pattern="+config.getDbTablePattern(); 
			    log.warn("DatabaseMetaData.getTables() returned no tables or views! Please check parameters: "+info);
				log.info("Available catalogs: " + getCatalogs(dbMeta));
				log.info("Available schemata: " + getSchemata(dbMeta));
			}
		} finally {
			DBUtil.close(tables, log);
		}
	}
	
    protected void gatherRelations(DBDatabase db, DatabaseMetaData dbMeta, ArrayList<String> tables) throws SQLException{
		ResultSet relations = null;
		String fkTableName, pkTableName, fkColName, pkColName, relName;
		DBTableColumn fkCol, pkCol;
		DBTable fkTable, pkTable;
		DBColumn col;
		
		// Add all Relations
		for (String tableName :tables) {
			
			// check for foreign-keys
			relations = dbMeta.getImportedKeys(config.getDbCatalog(), config .getDbSchema(), tableName);
			while (relations.next()) {
				pkCol=fkCol=null;
				
				fkTableName=relations.getString("FKTABLE_NAME");
				pkTableName=relations.getString("PKTABLE_NAME");
				fkColName=relations.getString("FKCOLUMN_NAME");
				pkColName=relations.getString("PKCOLUMN_NAME");

				// Detect relation name
				relName=relations.getString("FK_NAME");
				if (StringUtils.isEmpty(relName))
					relName=fkTableName+"."+fkColName+"-"+pkTableName+"."+pkColName;
				
				pkTable = db.getTable(pkTableName);
				fkTable = db.getTable(fkTableName);
				
				// check if both tables really exist in the model
				if(pkTable==null || fkTable==null){
					log.error("Unable to add the relation \""+relName+"\"! One of the tables could not be found.");
					continue;
				}
				
				col=pkTable.getColumn(pkColName);
				if(col instanceof DBTableColumn)
					pkCol = (DBTableColumn) col;
	
				col=fkTable.getColumn(fkColName);
				if(col instanceof DBTableColumn)
					fkCol = (DBTableColumn) col;
				
				// check if both columns really exist in the model
				if(fkCol==null || pkCol==null){
					log.error("Unable to add the relation \""+relName+"\"! One of the columns could not be found.");
					continue;
				}
				
				// add the relation
				DBRelation.DBReference reference = fkCol.referenceOn(pkCol);
				DBRelation.DBReference[] refs = null;
		    	DBRelation r = db.getRelation(relName);
		        if (r!=null) {
		        	DBRelation.DBReference[] refsOld = r.getReferences();
		        	refs = new DBRelation.DBReference[refsOld.length+1];
		        	int i=0;
		        	for (; i<refsOld.length; i++)
		        		refs[i]=refsOld[i];
	        		refs[i]=reference;
		        	// remove old relation
                    log.warn("Duplicate relation {}", r.getName());
	        		db.getRelations().remove(r);
		        } else {
		        	refs = new DBRelation.DBReference[] { reference };
		        }
				// Add a new relation
				db.addRelation(relName, refs);
				log.info("Added relation (FK-PK): "+relName);
			}
		}
	}

    protected String getCatalogs(DatabaseMetaData dbMeta) throws SQLException {
		String retVal = "";
		ResultSet rs = dbMeta.getCatalogs();
		while (rs.next()) {
			retVal += rs.getString("TABLE_CAT") + ", ";
		}
		if(retVal.length()>2)
			retVal=retVal.substring(0,retVal.length()-2);
		
		return retVal;
	}

    protected String getSchemata(DatabaseMetaData dbMeta) throws SQLException {
		String retVal = "";
		ResultSet rs = dbMeta.getSchemas();
		while (rs.next()) {
			retVal += rs.getString("TABLE_SCHEM") + ", ";
		}
		if(retVal.length()>2)
			retVal=retVal.substring(0,retVal.length()-2);
		return retVal;
	}

	/**
	 * queries the metadata for columns of a specific table and populates the
	 * table with that information
	 * @throws SQLException 
	 */
    protected void populateTable(DBTable t) throws SQLException {
		List<String> pkCols = this.findPkColumns(t.getName());
		String lockColName = config.getTimestampColumn();
		DBColumn[] keys = new DBColumn[pkCols.size()];
		ResultSet rs = null;
		try {
			rs = dbMeta.getColumns(config.getDbCatalog(), config.getDbSchema(),	t.getName(), null);
	        int i=0;
			while (rs.next()) {
				DBTableColumn c = addColumn(t, rs, lockColName);
				if (c==null)
				    continue;
				// check if it is a KeyColumn
				if (pkCols.contains(c.getName()))
					keys[i++] = c;
			}
	        // Check whether all key columns have been set
	        for (i=0; i<keys.length; i++)
	            if (keys[i]==null){
	            	throw new ItemNotFoundException(pkCols.get(i));
	            }
	        if(keys.length > 0){
	        	t.setPrimaryKey(keys);
	        }
		} finally {
			DBUtil.close(rs, log);
		}
	}
	
	/**
	 * queries the metadata for columns of a specific table and populates the
	 * table with that information
	 * @throws SQLException 
	 */
    protected void populateView(InMemoryView v) throws SQLException {
		ResultSet rs = null;
		try {
			rs = dbMeta.getColumns(config.getDbCatalog(), config.getDbSchema(),
					v.getName(), null);
			while (rs.next()) {
				addColumn(v, rs);
			}
		} finally {
			DBUtil.close(rs, log);
		}
	}

	/**
	 * Returns a list of column names that define the primarykey of the given
	 * table.
	 * @throws SQLException 
	 */
    protected List<String> findPkColumns(String tableName) throws SQLException {
		List<String> cols = new ArrayList<String>();
		ResultSet rs = null;
		try {
			rs = dbMeta.getPrimaryKeys(config.getDbCatalog(), config
					.getDbSchema(), tableName);
			while (rs.next()) {
				cols.add(rs.getString("COLUMN_NAME"));
			}
		} finally {
			DBUtil.close(rs, log);
		}
		return cols;
	}

	/**
	 * Adds DBColumn object to the given DBTable. The DBColumn is created from
	 * the given ResultSet
	 */
    protected DBTableColumn addColumn(DBTable t, ResultSet rs, String lockColName)
			throws SQLException {
		
	    String name = rs.getString("COLUMN_NAME");
	    if (!isPopulateColumn(name)) {
            log.info("Ignoring column " + name);
            return null;
	    }
	    
		DataType empireType = getEmpireDataType(rs.getInt("DATA_TYPE"));
		double colSize = getColumnSize(empireType, rs.getInt("DATA_TYPE"), rs.getInt("COLUMN_SIZE"));

		// Timestamp column
        if (empireType==DataType.DATETIME && StringUtils.isNotEmpty(lockColName) && name.equalsIgnoreCase(lockColName))
        {
            empireType=DataType.TIMESTAMP;
            colSize=0;
        }

        // Number column
		if (empireType==DataType.DECIMAL || empireType==DataType.FLOAT)
		{	// decimal digits
			int decimalDig = rs.getInt("DECIMAL_DIGITS");
			if (decimalDig>0)
			{	// parse
				try {
					int intSize = rs.getInt("COLUMN_SIZE");
					colSize = Double.parseDouble(String.valueOf(intSize)+'.'+decimalDig);
				} catch(Exception e) {
					log.error("Failed to parse decimal digits for column "+name);
				}
			}
			// make integer?
			if (colSize<1.0d)
			{	// Turn into an integer
				empireType=DataType.INTEGER;
			}
		}
		
		// mandatory field?
		boolean required = false;
		String defaultValue = rs.getString("COLUMN_DEF");
		if (rs.getString("IS_NULLABLE").equalsIgnoreCase("NO"))
			required = true;
		
		// The following is a hack for MySQL which currently gets sent a string "CURRENT_TIMESTAMP" from the Empire-db driver for MySQL.
		// This will avoid the driver problem because CURRENT_TIMESTAMP in the db will just do the current datetime.
		// Essentially, Empire-db needs the concept of default values of one type that get mapped to another.
		// In this case, MySQL "CURRENT_TIMESTAMP" for Types.TIMESTAMP needs to emit from the Empire-db driver the null value and not "CURRENT_TIMESTAMP".
		if(rs.getInt("DATA_TYPE") == Types.TIMESTAMP && defaultValue != null && defaultValue.equals("CURRENT_TIMESTAMP")){
			required = false; // It is in fact not required even though MySQL schema is required because it has a default value. Generally, should Empire-db emit (required && defaultValue != null) to truly determine if a column is required?
			defaultValue = null; // If null (and required per schema?) MySQL will apply internal default value.
		}
		
		// AUTOINC indicator is not in java.sql.Types but rather meta data from DatabaseMetaData.getColumns()
		// getEmpireDataType() above is not enough to support AUTOINC as it will only return DataType.INTEGER
		DataType originalType = empireType;
		ResultSetMetaData metaData = rs.getMetaData();
		int colCount = metaData.getColumnCount();
		String colName;
		for (int i = 1; i <= colCount; i++) {
			colName = metaData.getColumnName(i);
			// MySQL matches on IS_AUTOINCREMENT column.
			// SQL Server matches on TYPE_NAME column with identity somewhere in the string value.
			if ((colName.equalsIgnoreCase("IS_AUTOINCREMENT") && rs.getString(i).equalsIgnoreCase("YES")) ||
					(colName.equals("TYPE_NAME") && rs.getString(i).matches(".*(?i:identity).*"))){
				empireType = DataType.AUTOINC;
				
			}
		}
		
		// Move from the return statement below so we can add
		// some AUTOINC meta data to the column to be used by
		// the ParserUtil and ultimately the template.
		log.info("\tCOLUMN:\t" + name + " ("+empireType+")");
		DBTableColumn col = t.addColumn(name, empireType, colSize, required, defaultValue);
		
		// We still need to know the base data type for this AUTOINC
		// because the Record g/setters need to know this, right?
		// So, let's add it as meta data every time the column is AUTOINC
		// and reference it in the template.
		if(empireType.equals(DataType.AUTOINC))
			col.setAttribute("AutoIncDataType", originalType);
		return col;
		
	}
	
	protected double getColumnSize(DataType empireType, int dataType, int columnSize) {
		return columnSize;
	}

	/**
	 * Adds DBColumn object to the given DBTable. The DBColumn is created from
	 * the given ResultSet
	 */
	private DBViewColumn addColumn(InMemoryView v, ResultSet rs)
			throws SQLException {
		String name = rs.getString("COLUMN_NAME");
		DataType empireType = getEmpireDataType(rs.getInt("DATA_TYPE"));
		
		log.info("\tCOLUMN:\t" + name + " ("+empireType+")");
		return v.addCol(name, empireType);
	}

	/**
	 * converts a SQL DataType to a EmpireDataType
	 */
	private DataType getEmpireDataType(int sqlType) {
		DataType empireType = DataType.UNKNOWN;
		switch (sqlType) {
		case Types.INTEGER:
		case Types.SMALLINT:
		case Types.TINYINT:
		case Types.BIGINT:
			empireType = DataType.INTEGER;
			break;
		case Types.VARCHAR:
		case Types.NVARCHAR:
			empireType = DataType.VARCHAR;
			break;
		case Types.DATE:
			empireType = DataType.DATE;
			break;
		case Types.TIMESTAMP:
		case Types.TIME:
			empireType = DataType.DATETIME;
			break;
		case Types.CHAR:
        case Types.NCHAR:
			empireType = DataType.CHAR;
			break;
		case Types.DOUBLE:
		case Types.FLOAT:
		case Types.REAL:
			empireType = DataType.FLOAT;
			break;
		case Types.DECIMAL:
		case Types.NUMERIC:
			empireType = DataType.DECIMAL;
			break;
		case Types.BIT:
		case Types.BOOLEAN:
			empireType = DataType.BOOL;
			break;
		case Types.CLOB:
		case Types.NCLOB:
		case Types.LONGVARCHAR:
		case Types.LONGNVARCHAR:
			empireType = DataType.CLOB;
			break;
		case Types.BINARY:
		case Types.VARBINARY:
		case Types.LONGVARBINARY:
		case Types.BLOB:
			empireType = DataType.BLOB;
			break;
		default:
			empireType = DataType.UNKNOWN;
			log.warn("SQL column type " + sqlType + " not supported.");
		}
		log.debug("Mapping date type " + String.valueOf(sqlType) + " to "
				+ empireType);
		return empireType;
	}

}
