/*
 *  BaseLink - Generic object relational mapping
 *  Copyright (C) 2011  Ulrich Hilger, http://uhilger.de
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see http://www.gnu.org/licenses/
 */

package de.uhilger.baselink;

import java.io.ByteArrayInputStream;
import java.lang.reflect.Method;
import java.sql.Blob;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Universal mapping of objects to and from a database
 * 
 * Any class that has annotations for <code>DBTable</code> and 
 * <code>DBColumns</code> can be persisted using <code>GenericRecord</code>.
 * 
 * @author Copyright (c) Ulrich Hilger, http://uhilger.de
 * @author Published under the terms and conditions of
 * the <a href="http://www.gnu.org/licenses/" target="_blank">GNU General Public License</a>
 */
public class GenericRecord implements Record {
  
	/** default getter method indicator */
	public static final String GETTER_NAME = "get";
	/** default setter method indicator */
	public static final String SETTER_NAME = "set";
	
	/** name of table this instance of GenericRecord references */
	private String tableName;
	/** list of column names that make up the primary key */
	private List<String> primaryKeyColNames;
	/** reference to field setters and getters */
	private Hashtable<String,Field> columns;
	/** reference to class that this instance of GenericRecord maps to and from a database */
	private Class<?> recordClass;

	/**
	 * Create a new object of class GenericRecord
	 * @param c  the class to persist
	 */
	public GenericRecord(Class<?> c) {		
		Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(c.getName());
		this.recordClass = c;
		
		DBTable table = c.getAnnotation(DBTable.class);
		if(table != null) {
			Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(tableName);
			tableName = table.name();
		} else {
			Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).severe("missing table annotation");
		}
		
		primaryKeyColNames = new ArrayList<String>();
		DBPrimaryKey primaryKey = c.getAnnotation(DBPrimaryKey.class);
		if(primaryKey != null) {
			String[] names = primaryKey.value();
			for(int i = 0; i < names.length; i++) {
				Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(names[i]);
				primaryKeyColNames.add(names[i]);
			}
		} else {
			Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).severe("missing primary key annotation");
		}

		columns = new Hashtable<String,Field>();
		Method[] methods = c.getMethods();
		for(int i = 0; i < methods.length; i++) {
			Method method = methods[i];
			DBColumn field = method.getAnnotation(DBColumn.class);
			if(field != null) {
				Field mapper = new Field();
				String fieldName = field.name();
				mapper.setColumnType(field.type());
				mapper.setColumnName(fieldName);
				Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(fieldName);
				String methodName = method.getName();
				if(methodName.startsWith(GETTER_NAME)) {
					String fieldMethod = methodName.substring(3);
					Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(fieldMethod);
					Class<?> returnType = method.getReturnType();
					try {
						mapper.setSetter(c.getMethod(SETTER_NAME + fieldMethod, returnType));
					} catch (Exception e) {
						Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).log(Level.SEVERE, e.getLocalizedMessage(), e);
					}
					mapper.setGetter(method);
				}
				columns.put(fieldName, mapper);
			}
		}
	}
	
	/**
	 * Get a statement suitable to delete a given object from the database
	 * @param c  the database connection to use for the delete 
	 * @param record  the object to delete
	 * @return  the delete statement
	 * @throws Exception
	 */
	public PreparedStatement getDeleteStatment(Connection c, Object record) throws Exception {
		StringBuffer sql = new StringBuffer();
		sql.append("delete from ");
		sql.append(tableName);
		sql.append(" where ");
		appendPrimaryKeyFields(sql);
		Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(sql.toString());
		PreparedStatement ps = c.prepareStatement(sql.toString());
		prepareFields(0, primaryKeyColNames, ps, record);
		return ps;
	}

  /**
   * Get a statement suitable to insert a given object to the database
	 * @param c  the database connection to use for the insert 
	 * @param record  the object to insert
   * @param autoGeneratedKeys  a flag indicating whether auto-generated keys should be returned; 
   *          one of Statement.RETURN_GENERATED_KEYS or Statement.NO_GENERATED_KEYS
	 * @return  the insert statement
	 * @throws Exception
	 */
	public PreparedStatement getInsertStmt(Connection c, Object record, int autoGeneratedKeys) throws Exception {
		StringBuffer sql = new StringBuffer();
		sql.append("insert into ");
		sql.append(tableName);
		sql.append("(");
		Enumeration<String> fieldNames = columns.keys();
		StringBuffer fieldList = new StringBuffer();
		StringBuffer valueList = new StringBuffer();
		while(fieldNames.hasMoreElements()) {
			if(fieldList.length() > 0) {
				fieldList.append(",");
				valueList.append(",");
			}
			fieldList.append(fieldNames.nextElement());
			valueList.append("?");
		}
		sql.append(fieldList);
		sql.append(")");
		sql.append(" values (");//?, ?)");
		sql.append(valueList);
		sql.append(")");
		Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(sql.toString());
		PreparedStatement ps = c.prepareStatement(sql.toString(), autoGeneratedKeys);
		prepareFields(0, ps, record);
		return ps;
	}
  	
	/**
	 * Get a statement suitable to insert a given object to the database
	 * @param c  the database connection to use for the insert 
	 * @param record  the object to insert
	 * @return  the insert statement
	 * @throws Exception
	 */
	public PreparedStatement getInsertStatment(Connection c, Object record) throws Exception {
		return getInsertStmt(c, record, Statement.NO_GENERATED_KEYS);
	}
  
	/**
	 * Get a statement suitable to update a given object in the database
	 * @param c  the database connection to use for the update 
	 * @param record  the object to update
	 * @return  the update statement
	 * @throws Exception
	 */
	public PreparedStatement getUpdateStatment(Connection c, Object record) throws Exception {
		StringBuffer sql = new StringBuffer();
		sql.append("update ");
		sql.append(tableName);
		sql.append(" set ");		
		Enumeration<String> fieldNames = columns.keys();
		StringBuffer fieldList = new StringBuffer();
		while(fieldNames.hasMoreElements()) {
			if(fieldList.length() > 0) {
				fieldList.append(", ");
			}
			fieldList.append(fieldNames.nextElement());
			fieldList.append("=?");
		}
		sql.append(fieldList);
		sql.append(" where ");
		appendPrimaryKeyFields(sql);		
		Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(sql.toString());
		PreparedStatement ps = c.prepareStatement(sql.toString());
		prepareFields(0, ps, record);
		prepareFields(columns.size(), primaryKeyColNames, ps, record);
		return ps;
	}

	/**
	 * Get contents of this record as an object
	 * @param resultSet  a resultSet that points to the record to get as an object
	 * @param includeBlobs  indicator whether or not to include BLOBs 
	 * @return  an object having the data of this record 
	 * @throws Exception
	 */
	public Object toObject(ResultSet resultSet, boolean includeBlobs) throws Exception {
		Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(recordClass.getName());
		Object o = recordClass.newInstance();
		Enumeration<String> fieldNames = columns.keys();
		while(fieldNames.hasMoreElements()) {
			String columnName = fieldNames.nextElement();
			Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(columnName);
			Field mapper = columns.get(columnName);
			Method setter = mapper.getSetter();
			if(setter != null) {
				Object data = null;
				if(mapper.getColumnType().equals(DBColumn.Type.BLOB)) {
					if(includeBlobs) {
						Blob blob = resultSet.getBlob(columnName);
						data = new String(blob.getBytes((long) 1, (int) blob.length()));
						setter.invoke(o, data);
					}
				} else {
					data = resultSet.getObject(columnName);
					Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).finest(data.toString());
					setter.invoke(o, data);
				}
			}
		}
		return o;
	}
	 
	/**
	 * append names of primary key columns to an SQL string
	 * @param sql  the SQL string to append to
	 */
	private void appendPrimaryKeyFields(StringBuffer sql) {
		for(int i = 0; i < primaryKeyColNames.size(); i++) {
			if(i > 0) {
				sql.append(" and ");
			}
			sql.append(primaryKeyColNames.get(i));
			sql.append("=?");
		}
	}
	
	/**
	 * Prepare fields of a PreparedStatement
	 * @param offset  number of field to start with
	 * @param ps  the statement to prepare
	 * @param record  object that has the data for the statement
	 * @throws Exception
	 */
	private void prepareFields(int offset, PreparedStatement ps, Object record) throws Exception {
		Enumeration<String> fieldNames = columns.keys();
		int i = 1;
		while(fieldNames.hasMoreElements()) {
			Field mapper = columns.get(fieldNames.nextElement());
			Method getter = mapper.getGetter(); 
			Object o = getter.invoke(record, (Object[]) null);
			if(mapper.getColumnType().equals(DBColumn.Type.BLOB)) {
				String content = o.toString();
				ps.setBinaryStream(i+offset, new ByteArrayInputStream(content.getBytes()), content.length());
			} else {
				ps.setObject(i+offset, o);
			}
			i++;
		}
	}

	/**
	 * Prepare fields of a PreparedStatement
	 * @param offset  number of field to start with
	 * @param source  list of field names 
	 * @param ps  the statement to prepare
	 * @param record  object that has the data for the statement
	 * @throws Exception
	 */
	private void prepareFields(int offset, List<String> source, PreparedStatement ps, Object record) throws Exception {
		for(int i = 0; i < source.size(); i++) {
			Field mapper = columns.get(source.get(i));
			Method getter = mapper.getGetter();
			Object o = getter.invoke(record, (Object[]) null);
			ps.setObject(i+offset+1, o);
		}
	}
	
	/**
	 * Get the name of the database table this record references
	 * @return  the table name
	 */
	public String getTableName() {
		return tableName;
	}

}