package com.moparisthebest.jdbc;

/*
 * 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.
 *
 * $Header:$
 */

import java.lang.reflect.*;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map;

import static com.moparisthebest.jdbc.UpdateableDTO.YES;
import static com.moparisthebest.jdbc.UpdateableDTO.NO;

/**
 * Map a ResultSet row to an Object. This mapper uses Java reflection to perform the mapping.
 * <p/>
 * This class is modified from org.apache.beehive.controls.system.jdbc.RowToObjectMapper
 * <p/>
 * The column names are compared, case insensitive, and with and without underscores (_), to the set* methods
 * and fields of the given class to map the fields.  For example:
 * <p/>
 * USERID would prefer method setUserId(), and fall back to field userId if the method doesn't exist
 * USER_ID would act the same as the above, but also additionally try to match method setUser_Id(), or field user_id
 * <p/>
 * First, this class will look for a constructor that takes a ResultSet as a parameter, if it finds one, it will
 * instantiate it with that constructor, sending in the ResultSet.  Otherwise, this will only try to use public
 * setters, but will set fields regardless of specified access, even private fields.
 *
 * @author Travis Burtrum (modifications from beehive)
 */
public class RowToObjectMapper<T> extends RowMapper {

	public static final int TYPE_BOOLEAN = _tmf.getTypeId(Boolean.TYPE);//TypeMappingsFactory.TYPE_BOOLEAN; // not public? 
	public static final int TYPE_BOOLEAN_OBJ = _tmf.getTypeId(Boolean.class);//TypeMappingsFactory.TYPE_BOOLEAN_OBJ; // not public?

	protected final int _columnCount;
	protected final Constructor<T> resultSetConstructor;
	protected final Class<? extends T> _returnTypeClass; // over-ride non-generic version of this in super class
	
	// only non-null when _returnTypeClass is an array, or a map
	protected final Class<?> componentType;
	protected final boolean returnMap;     	

	protected AccessibleObject[] _fields;
	protected int[] _fieldTypes;

	protected final Object[] _args = new Object[1];

	public RowToObjectMapper(ResultSet resultSet, Class<T> returnTypeClass) {
		this(resultSet, returnTypeClass, null, null);	
	}

	public RowToObjectMapper(ResultSet resultSet, Class<T> returnTypeClass, Class<?> mapValType) {
		this(resultSet, returnTypeClass, null, mapValType);
	}

	public RowToObjectMapper(ResultSet resultSet, Class<T> returnTypeClass, Calendar cal) {
		this(resultSet, returnTypeClass, cal, null);
	}

	/**
	 * Create a new RowToObjectMapper.
	 *
	 * @param resultSet       ResultSet to map
	 * @param returnTypeClass Class to map to.
	 * @param cal             Calendar instance for date/time mappings.
	 */
	public RowToObjectMapper(ResultSet resultSet, Class<T> returnTypeClass, Calendar cal, Class<?> mapValType) {
		super(resultSet, returnTypeClass, cal);
		returnMap = Map.class.isAssignableFrom(returnTypeClass);
		if(returnMap){
			_returnTypeClass = ResultSetMapper.getConcreteClass(returnTypeClass, HashMap.class);
			componentType = mapValType;
		}else{
			_returnTypeClass = returnTypeClass;
			// detect if we want an array back
			componentType = returnTypeClass.getComponentType();
		}

		_fields = null;

		// detect if returnTypeClass has a constructor that takes a ResultSet, if so, our job couldn't be easier...
		Constructor<T> resultSetConstructor = null;
		try {
			resultSetConstructor = returnTypeClass.getConstructor(ResultSet.class);
			if (!resultSetConstructor.isAccessible())
				resultSetConstructor.setAccessible(true);
		} catch (Throwable e) {
			// do nothing, no such constructor
		}
		this.resultSetConstructor = resultSetConstructor;

		try {
			_columnCount = resultSet.getMetaData().getColumnCount();
		} catch (SQLException e) {
			throw new MapperException("RowToObjectMapper: SQLException: " + e.getMessage(), e);
		}
	}

	/**
	 * This returns a new instance of the Map class required by Map<String, Object>[]
	 * It lives in it's own method to minimize suppressed warnings and allow subclasses to override methods,
	 * like perhaps to implement the original beehive behavior of case-insensitive strings
	 */
	@SuppressWarnings({"unchecked"})
	protected Map<String, Object> getMapImplementation() throws IllegalAccessException, InstantiationException {
		return (Map<String, Object>)_returnTypeClass.newInstance();
	}

	/**
	 * Do the mapping.
	 *
	 * @return An object instance.
	 */
	public T mapRowToReturnType() {

		if (resultSetConstructor != null)
			try {
				return resultSetConstructor.newInstance(_resultSet);
			} catch (Throwable e) {
				throw new MapperException(e.getClass().getName() + " when trying to create instance of : "
						+ _returnTypeClass.getName() + " sending in a ResultSet object as a parameter", e);
			}  		
		
		if(returnMap) // we want a map
			try {
				final Map<String, Object> ret = getMapImplementation();
				final ResultSetMetaData md = _resultSet.getMetaData();
				final int columnLength = _columnCount+1;
				if(componentType != null && componentType != Object.class){ // we want a specific value type
					int typeId = _tmf.getTypeId(componentType);
					for(int x = 1; x < columnLength; ++x)
						ret.put(md.getColumnName(x).toLowerCase(), extractColumnValue(x, typeId));
				} else // we want a generic object type
					for(int x = 1; x < columnLength; ++x)
						ret.put(md.getColumnName(x).toLowerCase(), _resultSet.getObject(x));
				return _returnTypeClass.cast(ret);
			} catch (Throwable e) {
				throw new MapperException(e.getClass().getName() + " when trying to create a Map<String, "
						+ (componentType == null ? "java.lang.Object" : componentType.getName()) + "> from a ResultSet row" +
						", all columns must be of the map value type", e);
			}
		else if(componentType != null) // we want an array
			try {
				final Object[] ret = (Object[])Array.newInstance(componentType, _columnCount);
				final int typeId = _tmf.getTypeId(componentType);
				for(int x = 0; x < _columnCount;)
					ret[x] = extractColumnValue(++x, typeId);
				return _returnTypeClass.cast(ret);
			} catch (Throwable e) {
				throw new MapperException(e.getClass().getName() + " when trying to create a "
						+ componentType.getName() + "[] from a ResultSet row, all columns must be of that type", e);
			}


		T resultObject = null;

		// if the ResultSet only contains a single column we may be able to map directly
		// to the return type -- if so we don't need to build any structures to support
		// mapping
		if (_columnCount == 1) {

			final int typeId = _tmf.getTypeId(_returnTypeClass);

			try {
				if (typeId != TypeMappingsFactory.TYPE_UNKNOWN) {
					return _returnTypeClass.cast(extractColumnValue(1, typeId));
				} else {
					// we still might want a single value (i.e. java.util.Date)
					Object val = extractColumnValue(1, typeId);
					if (_returnTypeClass.isAssignableFrom(val.getClass())) {
						return _returnTypeClass.cast(val);
					}
				}
			} catch (Exception e) {
				throw new MapperException(e.getMessage(), e);
			}
		}

		if (_fields == null) {
			try {
				getFieldMappings();
			} catch (SQLException e) {
				throw new MapperException(e.getMessage(), e);
			}
		}

		try {
			resultObject = _returnTypeClass.newInstance();
		} catch (Throwable e) {
			throw new MapperException(e.getClass().getName() + " when trying to create instance of : "
					+ _returnTypeClass.getName(), e);
		}

		for (int i = 1; i < _fields.length; i++) {
			AccessibleObject f = _fields[i];

			try {
				_args[0] = extractColumnValue(i, _fieldTypes[i]);
				//System.out.printf("field: '%s' obj: '%s' fieldType: '%s'\n", _fields[i], _args[0], _fieldTypes[i]);
				// custom hacked-in support for enums, can do better when we scrap org.apache.beehive.controls.system.jdbc.TypeMappingsFactory
				if(_fieldTypes[i] == 0 && _args[0] instanceof String){
					Class<?> fieldType = f instanceof Field ? ((Field)f).getType() : ((Method)f).getParameterTypes()[0];
					if(Enum.class.isAssignableFrom(fieldType))
						_args[0] = Enum.valueOf((Class<? extends Enum>)fieldType, (String)_args[0]);
				}
				if (f instanceof Field) {
					((Field) f).set(resultObject, _args[0]);
				} else {
					((Method) f).invoke(resultObject, _args);
				}
			} catch (SQLException e) {
				throw new MapperException(e.getMessage(), e);
			} catch (IllegalArgumentException iae) {

				try {
					ResultSetMetaData md = _resultSet.getMetaData();
					if (f instanceof Field) {
						throw new MapperException("The declared Java type for field " + ((Field) f).getName()
								+ ((Field) f).getType().toString()
								+ " is incompatible with the SQL format of column " + i + " '" + md.getColumnName(i)
								+ "' (" + md.getColumnTypeName(i)
								+ ") which returns objects of type " + _args[0].getClass().getName());
					} else {
						throw new MapperException("The declared Java type for method " + ((Method) f).getName()
								+ ((Method) f).getParameterTypes()[0].toString()
								+ " is incompatible with the SQL format of column " + i + " '" + md.getColumnName(i)
								+ "' (" + md.getColumnTypeName(i)
								+ ") which returns objects of type " + _args[0].getClass().getName());
					}
				} catch (SQLException e) {
					throw new MapperException(e.getMessage(), e);
				}

			} catch (IllegalAccessException e) {
				if (f instanceof Field) {
					throw new MapperException("IllegalAccessException when trying to access field " + ((Field) f).getName(), e);
				} else {
					throw new MapperException("IllegalAccessException when trying to access method " + ((Method) f).getName(), e);
				}
			} catch (InvocationTargetException e) {
				throw new MapperException("InvocationTargetException when trying to access method " + ((Method) f).getName(), e);
			}
		}
		// if this resultObject is Finishable, call finish()
		if (resultObject instanceof Finishable)
			try {
				((Finishable) resultObject).finish(_resultSet);
			} catch (SQLException e) {
				throw new MapperException(e.getMessage(), e);
			}
		return resultObject;
	}

	/**
	 * Provided so we can extract a column value to use for the key of a map
	 *
	 * @param index
	 * @param classType
	 * @return
	 * @throws SQLException
	 */
	@SuppressWarnings({"unchecked"})
	public <E> E extractColumnValue(int index, Class<E> classType) throws SQLException {
			return classType.cast(extractColumnValue(index, _tmf.getTypeId(classType)));
	}

	/**
	 * Provided so we know whether to map all values to a type, or just the second one
	 *
	 * @return
	 */
	public int getColumnCount() {
		return this._columnCount;
	}

	/**
	 * Build the structures necessary to do the mapping
	 *
	 * @throws SQLException on error.
	 */
	protected void getFieldMappings()
			throws SQLException {

		final String[] keys = getKeysFromResultSet();
		//System.out.println("keys: "+ Arrays.toString(keys));

		// added this to handle stripping '_' from keys
		Map<String, String> strippedKeys = new HashMap<String, String>();
		for (final String key : keys) {
			String strippedKey = key;
			if (key != null) {
				strippedKey = key.replaceAll("_", "");
				if (key.equals(strippedKey))
					continue;
				strippedKeys.put(strippedKey, key);
			}
		}
		//System.out.println("strippedKeys: "+strippedKeys);
		//
		// find fields or setters for return class
		//
		HashMap<String, AccessibleObject> mapFields = new HashMap<String, AccessibleObject>(_columnCount * 2);
		for (int i = 1; i <= _columnCount; i++) {
			mapFields.put(keys[i], null);
		}

		// public methods
		Method[] classMethods = _returnTypeClass.getMethods();
		for (Method m : classMethods) {
			//System.out.printf("method: '%s', isSetterMethod: '%s'\n", m, isSetterMethod(m));
			if (isSetterMethod(m)) {
				String fieldName = m.getName().substring(3).toUpperCase();
				//System.out.println("METHOD-fieldName: "+fieldName);
				if (!mapFields.containsKey(fieldName)) {
					fieldName = strippedKeys.get(fieldName);
					if (fieldName == null)
						continue;
				}
				// check for overloads
				Object field = mapFields.get(fieldName);
				if (field == null) {
					mapFields.put(fieldName, m);
				} else {
					throw new MapperException("Unable to choose between overloaded methods " + m.getName()
							+ " on the " + _returnTypeClass.getName() + " class. Mapping is done using "
							+ "a case insensitive comparison of SQL ResultSet columns to field "
							+ "names and public setter methods on the return class. Columns are also "
							+ "stripped of '_' and compared if no match is found with them.");
				}
			}
		}

		// fix for 8813: include inherited and non-public fields
		for (Class clazz = _returnTypeClass; clazz != null && clazz != Object.class; clazz = clazz.getSuperclass()) {
			//System.out.println("searching for fields in class: "+clazz.getName());
			Field[] classFields = clazz.getDeclaredFields();
			//System.out.println("fields in class: "+Arrays.toString(classFields));
			for (Field f : classFields) {
				if (Modifier.isStatic(f.getModifiers())) continue;
				//if (!Modifier.isPublic(f.getModifiers())) continue;  // commented this out to work on all types of fields
				String fieldName = f.getName().toUpperCase();
				//System.out.println("fieldName: "+fieldName);
				if (!mapFields.containsKey(fieldName)) {
					fieldName = strippedKeys.get(fieldName);
					if (fieldName == null)
						continue;
				}
				Object field = mapFields.get(fieldName);
				if (field == null) {
					mapFields.put(fieldName, f);
				}
			}
		}

		// finally actually init the fields array
		_fields = new AccessibleObject[_columnCount + 1];
		_fieldTypes = new int[_columnCount + 1];

		for (int i = 1; i < _fields.length; i++) {
			AccessibleObject f = mapFields.get(keys[i]);
			if (f == null) {
				throw new MapperException("Unable to map the SQL column " + keys[i]
						+ " to a field on the " + _returnTypeClass.getName() +
						" class. Mapping is done using a case insensitive comparision of SQL ResultSet "
						+ "columns to field "
						+ "names and public setter methods on the return class. Columns are also "
						+ "stripped of '_' and compared if no match is found with them.");
			}
			f.setAccessible(true);
			_fields[i] = f;
			if (f instanceof Field) {
				_fieldTypes[i] = _tmf.getTypeId(((Field) f).getType());
			} else {
				_fieldTypes[i] = _tmf.getTypeId(((Method) f).getParameterTypes()[0]);
			}
		}
	}

	@Override
	protected Object extractColumnValue(int index, int resultType) throws SQLException {
		try{
			if (resultType != TYPE_BOOLEAN && resultType != TYPE_BOOLEAN_OBJ)
				return super.extractColumnValue(index, resultType);
			else {
				// do some special handling to convert a database string to a boolean
				boolean ret;
				try {
					// try to get an actual boolean from the database
					ret = _resultSet.getBoolean(index);
					// null seems to get returned as false above, so String code won't run if its null
					if (_resultSet.wasNull())
						if (resultType == TYPE_BOOLEAN_OBJ)
							return null; // only return null for Boolean object
						else
							throw new MapperException(String.format("Implicit conversion of database string to boolean failed on column '%d'. Returned string needs to be 'Y' or 'N' and was instead 'null'. If you want to accept null values, make it an object Boolean instead of primitive boolean.", index));
				} catch (SQLException e) {
					// if we are here, it wasn't a boolean or null, so try to grab a string instead
					String bool = _resultSet.getString(index);//.toUpperCase(); // do we want it case-insensitive?
					ret = YES.equals(bool);
					if (!ret && !NO.equals(bool))
						throw new MapperException(String.format("Implicit conversion of database string to boolean failed on column '%d'. Returned string needs to be 'Y' or 'N' and was instead '%s'.", index, bool));
					//throw e;
				}
				return ret ? Boolean.TRUE : Boolean.FALSE;
			}
		}catch(SQLException e){
			throw new SQLExceptionColumnNum(e, index);
		}
	}

	public static <T> T fixNull(Class<T> returnType) {
		return returnType.cast(_tmf.fixNull(returnType));
	}
}

