/*
 * Copyright (c) 2008, Harald Kuhr
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *     * Redistributions of source code must retain the above copyright
 *       notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in the
 *       documentation and/or other materials provided with the distribution.
 *     * Neither the name "TwelveMonkeys" nor the
 *       names of its contributors may be used to endorse or promote products
 *       derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.twelvemonkeys.sql;

import com.twelvemonkeys.lang.*;

import java.lang.reflect.*;

// Single-type import, to avoid util.Date/sql.Date confusion
import java.util.Hashtable;
import java.util.Vector;
import java.util.Enumeration;
import java.util.StringTokenizer;

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

/**
 * A class for mapping JDBC ResultSet rows to Java objects.
 * 
 * @see ObjectReader
 *
 * @author Harald Kuhr (haraldk@iconmedialab.no)
 * @author last modified by $Author: haku $
 *
 * @version $Id: //depot/branches/personal/haraldk/twelvemonkeys/release-2/twelvemonkeys-sandbox/src/main/java/com/twelvemonkeys/sql/ObjectMapper.java#1 $
 *
 * @todo Use JDK logging instead of proprietary logging.
 */
public class ObjectMapper {
    final static String DIRECTMAP = "direct";
    final static String OBJECTMAP = "object";
    final static String COLLECTIONMAP = "collection";
    final static String OBJCOLLMAP = "objectcollection";
    
    Class mInstanceClass = null;

    Hashtable mMethods = null;

    Hashtable mColumnMap = null;
    Hashtable mPropertiesMap = null;

    Hashtable mJoins = null;

    private Hashtable mTables = null;
    private Vector mColumns = null;

    Hashtable mForeignKeys = null;
    Hashtable mPrimaryKeys = null;
    Hashtable mMapTypes = null;
    Hashtable mClasses = null;

    String mPrimaryKey = null;
    String mForeignKey = null;
    String mIdentityJoin = null;

    Log mLog = null;

    /**
     * Creates a new ObjectMapper for a DatabaseReadable 
     *
     * @param obj An object of type DatabaseReadable
     */

    /*
      public ObjectMapper(DatabaseReadable obj) {
      this(obj.getClass(), obj.getMapping());
      }
    */
    /**
     * Creates a new ObjectMapper for any object, given a mapping
     *
     * @param objClass The class of the object(s) created by this OM
     * @param mapping an Hashtable containing the mapping information 
     * for this OM
     */

    public ObjectMapper(Class pObjClass, Hashtable pMapping) {
        mLog = new Log(this);

        mInstanceClass = pObjClass;

        mJoins = new Hashtable();
        mPropertiesMap = new Hashtable();
        mColumnMap = new Hashtable();

        mClasses = new Hashtable();
        mMapTypes = new Hashtable();
        mForeignKeys = new Hashtable();
        mPrimaryKeys = new Hashtable();

        // Unpack and store mapping information
        for (Enumeration keys = pMapping.keys(); keys.hasMoreElements();) {
            String key  = (String) keys.nextElement();
            String value = (String) pMapping.get(key);
	    
            int dotIdx = key.indexOf(".");
	    
            if (dotIdx >= 0) {
                if (key.equals(".primaryKey")) {
                    // Primary key
                    mPrimaryKey = (String) pMapping.get(value); 
                }
                else if (key.equals(".foreignKey")) {
                    // Foreign key
                    mForeignKey = (String) pMapping.get(value);
                }
                else if (key.equals(".join")) {
                    // Identity join
                    mIdentityJoin = (String) pMapping.get(key);
                }
                else if (key.endsWith(".primaryKey")) {
                    // Primary key in joining table
                    mPrimaryKeys.put(key.substring(0, dotIdx), value);
                }
                else if (key.endsWith(".foreignKey")) {
                    // Foreign key
                    mForeignKeys.put(key.substring(0, dotIdx), value);
                }
                else if (key.endsWith(".join")) {
                    // Joins
                    mJoins.put(key.substring(0, dotIdx), value);
                }
                else if (key.endsWith(".mapType")) {
                    // Maptypes
                    value = value.toLowerCase();
		    
                    if (value.equals(DIRECTMAP) || value.equals(OBJECTMAP) || 
                        value.equals(COLLECTIONMAP) || 
                        value.equals(OBJCOLLMAP)) {
                        mMapTypes.put(key.substring(0, dotIdx), value);
                    }
                    else {
                        mLog.logError("Illegal mapType: \"" + value + "\"! " 
                                      + "Legal types are: direct, object, "
                                      + "collection and objectCollection.");
                    }
                }
                else if (key.endsWith(".class")) {
                    // Classes
                    try {
                        mClasses.put(key.substring(0, dotIdx), 
                                     Class.forName(value));
                    }
                    catch (ClassNotFoundException e) {
                        mLog.logError(e);
                        //e.printStackTrace();
                    }
                }
                else if (key.endsWith(".collection")) {
                    // TODO!!
                }
            }
            else {
                // Property to column mappings
                mPropertiesMap.put(key, value);
                mColumnMap.put(value.substring(value.lastIndexOf(".") + 1), 
                               key);
            }
        }

        mMethods = new Hashtable();
        Method[] methods = mInstanceClass.getMethods();
        for (int i = 0; i < methods.length; i++) {
            // Two methods CAN have same name...    
            mMethods.put(methods[i].getName(), methods[i]); 
        }
    }

    public void setPrimaryKey(String pPrimaryKey) {
        mPrimaryKey = pPrimaryKey;
    }
    
    /**
     * Gets the name of the property, that acts as the unique identifier for
     * this ObjectMappers type.
     *
     * @return The name of the primary key property
     */

    public String getPrimaryKey() {
        return mPrimaryKey;
    }

    public String getForeignKey() {
        return mForeignKey;
    }

    /**
     * Gets the join, that is needed to find this ObjectMappers type.
     *
     * @return The name of the primary key property
     */

    public String getIdentityJoin() {
        return mIdentityJoin;
    }

    Hashtable getPropertyMapping(String pProperty) {
        Hashtable mapping = new Hashtable();

        if (pProperty != null) {
            // Property
            if (mPropertiesMap.containsKey(pProperty))
                mapping.put("object", mPropertiesMap.get(pProperty));

            // Primary key
            if (mPrimaryKeys.containsKey(pProperty)) {
                mapping.put(".primaryKey", "id");
                mapping.put("id", mPrimaryKeys.get(pProperty));
            }

            //Foreign key
            if (mForeignKeys.containsKey(pProperty))
                mapping.put(".foreignKey", mPropertiesMap.get(mForeignKeys.get(pProperty)));

            // Join
            if (mJoins.containsKey(pProperty))
                mapping.put(".join", mJoins.get(pProperty));

            // mapType
            mapping.put(".mapType", "object");
        }

        return mapping;
    }
    

    /**
     * Gets the column for a given property.
     *
     * @param property The property
     * @return The name of the matching database column, on the form 
     *         table.column
     */

    public String getColumn(String pProperty) {
        if (mPropertiesMap == null || pProperty == null)
            return null;
        return (String) mPropertiesMap.get(pProperty);
    }

    /**
     * Gets the table name for a given property.
     *
     * @param property The property
     * @return The name of the matching database table.
     */

    public String getTable(String pProperty) {
        String table = getColumn(pProperty);

        if (table != null) {
            int dotIdx = 0;
            if ((dotIdx = table.lastIndexOf(".")) >= 0)
                table = table.substring(0, dotIdx);
            else
                return null;
        }

        return table;
    }

    /**
     * Gets the property for a given database column. If the column incudes
     * table qualifier, the table qualifier is removed.
     *
     * @param column The name of the column
     * @return The name of the mathcing property
     */

    public String getProperty(String pColumn) {
        if (mColumnMap == null || pColumn == null)
            return null;

        String property = (String) mColumnMap.get(pColumn);

        int dotIdx = 0;
        if (property == null && (dotIdx = pColumn.lastIndexOf(".")) >= 0)
            property = (String) mColumnMap.get(pColumn.substring(dotIdx + 1));

        return property;
    }


    /**
     * Maps each row of the given result set to an object ot this OM's type.
     *
     * @param rs The ResultSet to process (map to objects)
     * @return An array of objects (of this OM's class). If there are no rows  
     * in the ResultSet, an empty (zero-length) array will be returned.
     */

    public synchronized Object[] mapObjects(ResultSet pRSet) throws SQLException {
        Vector result = new Vector();

        ResultSetMetaData meta = pRSet.getMetaData();
        int cols = meta.getColumnCount();

        // Get colum names
        String[] colNames = new String[cols];
        for (int i = 0; i < cols; i++) {
            colNames[i] = meta.getColumnName(i + 1); // JDBC cols start at 1...

            /*
              System.out.println(meta.getColumnLabel(i + 1));
              System.out.println(meta.getColumnName(i + 1));
              System.out.println(meta.getColumnType(i + 1));
              System.out.println(meta.getColumnTypeName(i + 1));
              //	    System.out.println(meta.getTableName(i + 1));
              //	    System.out.println(meta.getCatalogName(i + 1));
              //	    System.out.println(meta.getSchemaName(i + 1));
              // Last three NOT IMPLEMENTED!!
              */
        }

        // Loop through rows in resultset
        while (pRSet.next()) {
            Object obj = null;
	    
            try {
                obj = mInstanceClass.newInstance(); // Asserts empty constructor!
            }
            catch (IllegalAccessException iae) {
                mLog.logError(iae);
                // iae.printStackTrace();
            }
            catch (InstantiationException ie) {
                mLog.logError(ie);
                // ie.printStackTrace();
            }
	    
            // Read each colum from this row into object 
            for (int i = 0; i < cols; i++) {

                String property = (String) mColumnMap.get(colNames[i]);

                if (property != null) {
                    // This column is mapped to a property
                    mapColumnProperty(pRSet, i + 1, property, obj);
                }
            }

            // Add object to the result Vector
            result.addElement(obj);
        }	
	
        return result.toArray((Object[]) Array.newInstance(mInstanceClass, 
                                                           result.size()));
    }

    /**
     * Maps a ResultSet column (from the current ResultSet row) to a named 
     * property of an object, using reflection.
     *
     * @param rs The JDBC ResultSet
     * @param index The column index to get the value from
     * @param property The name of the property to set the value of
     * @param obj The object to set the property to
     */

    void mapColumnProperty(ResultSet pRSet, int pIndex, String pProperty, 
                           Object pObj) {
        if (pRSet == null || pProperty == null || pObj == null)
            throw new IllegalArgumentException("ResultSet, Property or Object"
                                               + " arguments cannot be null!");
        if (pIndex <= 0)
            throw new IllegalArgumentException("Index parameter must be > 0!");
	    
        String methodName = "set" + StringUtil.capitalize(pProperty);
        Method setMethod = (Method) mMethods.get(methodName);
	
        if (setMethod == null) {
            // No setMethod for this property
            mLog.logError("No set method for property \"" 
                          + pProperty + "\" in " + pObj.getClass() + "!");
            return;
        }
	
        // System.err.println("DEBUG: setMethod=" + setMethod);
	
        Method getMethod = null;
	
        String type = "";
        try {
            Class[] cl = {Integer.TYPE};
            type = setMethod.getParameterTypes()[0].getName();
	    
            type = type.substring(type.lastIndexOf(".") + 1);
	    
            // There is no getInteger, use getInt instead
            if (type.equals("Integer")) {
                type = "int";
            }
	    
            // System.err.println("DEBUG: type=" + type);	    
            getMethod = pRSet.getClass().
                getMethod("get" + StringUtil.capitalize(type), cl);
        }
        catch (Exception e) {
            mLog.logError("Can't find method \"get" 
                          + StringUtil.capitalize(type) + "(int)\" " 
                          + "(for class " + StringUtil.capitalize(type) 
                          + ") in ResultSet", e);
	    
            return;
        }
	
        try {
            // Get the data from the DB
            // System.err.println("DEBUG: " + getMethod.getName() + "(" + (i + 1) + ")");
	    
            Object[] colIdx = {new Integer(pIndex)};
            Object[] arg = {getMethod.invoke(pRSet, colIdx)};
	    
            // Set it to the object
            // System.err.println("DEBUG: " + setMethod.getName() + "(" + arg[0] + ")");
            setMethod.invoke(pObj, arg);
        }
        catch (InvocationTargetException ite) {
            mLog.logError(ite);
            // ite.printStackTrace();
        }		    	    
        catch (IllegalAccessException iae) {
            mLog.logError(iae);
            // iae.printStackTrace();
        }		    
    }

    /**
     * Creates a SQL query string to get the primary keys for this 
     * ObjectMapper.
     */

    String buildIdentitySQL(String[] pKeys) {
        mTables = new Hashtable();
        mColumns = new Vector();

        // Get columns to select
        mColumns.addElement(getPrimaryKey());

        // Get tables to select (and join) from and their joins 
        tableJoins(null, false);

        for (int i = 0; i < pKeys.length; i++) {
            tableJoins(getColumn(pKeys[i]), true);
        }

        // All data read, build SQL query string
        return "SELECT " + getPrimaryKey() + " " + buildFromClause() 
            + buildWhereClause(); 
    }

    /**
     * Creates a SQL query string to get objects for this ObjectMapper.
     */

    public String buildSQL() {
        mTables = new Hashtable();
        mColumns = new Vector();

        String key = null;
        for (Enumeration keys = mPropertiesMap.keys(); keys.hasMoreElements();) {
            key = (String) keys.nextElement();
    
            // Get columns to select
            String column = (String) mPropertiesMap.get(key);
            mColumns.addElement(column);
    
            tableJoins(column, false);
        }

        // All data read, build SQL query string
        return buildSelectClause() + buildFromClause() 
            + buildWhereClause(); 
    }

    /**
     * Builds a SQL SELECT clause from  the columns Vector
     */

    private String buildSelectClause() {
        StringBuilder sqlBuf = new StringBuilder();

        sqlBuf.append("SELECT ");

        String column = null;
        for (Enumeration select = mColumns.elements(); select.hasMoreElements();) {
            column = (String) select.nextElement();

            /*
              String subColumn = column.substring(column.indexOf(".") + 1);
              // System.err.println("DEBUG: col=" + subColumn);
              String mapType = (String) mMapTypes.get(mColumnMap.get(subColumn));
            */
            String mapType = (String) mMapTypes.get(getProperty(column));

            if (mapType == null || mapType.equals(DIRECTMAP)) {
                sqlBuf.append(column);

                sqlBuf.append(select.hasMoreElements() ? ", " : " ");
            }
        }
	
        return sqlBuf.toString();
    }

    /**
     * Builds a SQL FROM clause from the tables/joins Hashtable
     */

    private String buildFromClause() {
        StringBuilder sqlBuf = new StringBuilder();

        sqlBuf.append("FROM ");

        String table = null;
        String schema = null;
        for (Enumeration from = mTables.keys(); from.hasMoreElements();) {
            table = (String) from.nextElement();
            /*
              schema = (String) schemas.get(table);

              if (schema != null)
              sqlBuf.append(schema + ".");
            */

            sqlBuf.append(table);
            sqlBuf.append(from.hasMoreElements() ? ", " : " ");
        }

        return sqlBuf.toString();
    }

    /**
     * Builds a SQL WHERE clause from the tables/joins Hashtable
     *
     * @return Currently, this metod will return "WHERE 1 = 1", if no other 
     *         WHERE conditions are specified. This can be considered a hack.
     */

    private String buildWhereClause() {
	
        StringBuilder sqlBuf = new StringBuilder();
       
        String join = null;
        boolean first = true;

        for (Enumeration where = mTables.elements(); where.hasMoreElements();) {
            join = (String) where.nextElement();
	    
            if (join.length() > 0) {
                if (first) {
                    // Skip " AND " in first iteration
                    first = false;
                }
                else {
                    sqlBuf.append(" AND ");
                }
            }

            sqlBuf.append(join);
        }

        if (sqlBuf.length() > 0) 
            return "WHERE " + sqlBuf.toString();
	
        return "WHERE 1 = 1"; // Hacky...
    }

    /**
     * Finds tables used in mappings and joins and adds them to the tables
     * Hashtable, with the table name as key, and the join as value.
     */

    private void tableJoins(String pColumn, boolean pWhereJoin) {
        String join = null;
        String table = null;
	    
        if (pColumn == null) {
            // Identity
            join = getIdentityJoin();
            table = getTable(getProperty(getPrimaryKey()));
        }
        else {
            // Normal
            int dotIndex = -1;
            if ((dotIndex = pColumn.lastIndexOf(".")) <= 0) {
                // No table qualifier
                return;
            }
	    
            // Get table qualifier.
            table = pColumn.substring(0, dotIndex);
	    
            // Don't care about the tables that are not supposed to be selected from
            String property = (String) getProperty(pColumn);
	    
            if (property != null) {
                String mapType = (String) mMapTypes.get(property);
                if (!pWhereJoin && mapType != null && !mapType.equals(DIRECTMAP)) {
                    return;
                }

                join = (String) mJoins.get(property);    
            }	
        }

        // If table is not in the tables Hash, add it, and check for joins.
        if (mTables.get(table) == null) {
            if (join != null) {
                mTables.put(table, join);
		
                StringTokenizer tok = new StringTokenizer(join, "= ");
                String next = null;
		
                while(tok.hasMoreElements()) {
                    next = tok.nextToken();
                    // Don't care about SQL keywords
                    if (next.equals("AND") || next.equals("OR") 
                        || next.equals("NOT") || next.equals("IN")) {
                        continue;
                    }
                    // Check for new tables and joins in this join clause.
                    tableJoins(next, false);
                }
            }
            else {
                // No joins for this table.
                join = "";
                mTables.put(table, join);
            }
        }    
    }

}
