/*
 * Copyright 2009-2015 Jose Luis Martin.
 *
 * 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.jdal.dao.hibernate;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.Criteria;
import org.hibernate.HibernateException;
import org.hibernate.LockOptions;
import org.hibernate.Query;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Example;
import org.hibernate.criterion.Order;
import org.hibernate.criterion.Projections;
import org.hibernate.impl.CriteriaImpl;
import org.hibernate.metadata.ClassMetadata;
import org.hibernate.transform.ResultTransformer;
import org.hibernate.type.Type;
import org.jdal.beans.PropertyUtils;
import org.jdal.dao.DaoSupport;
import org.jdal.dao.Filter;
import org.jdal.dao.Page;
import org.jdal.hibernate.HibernateUtils;
import org.springframework.beans.PropertyAccessor;
import org.springframework.orm.ObjectRetrievalFailureException;
import org.springframework.orm.hibernate3.HibernateTemplate;
import org.springframework.util.ClassUtils;

/**
 * Hibernate generic DAO implementation. Support pagination of results and filters
 * using the {@link #getPage(Page)} method.
 *
 * @author Jose Luis Martin
 * @see org.jdal.dao.Dao
 * @since 1.0
 */
public class HibernateDao<T, PK extends Serializable> extends DaoSupport<T, PK>{

	private static final Log log = LogFactory.getLog(HibernateDao.class);
	
	private Class<T> entityClass;
	private boolean cachePageQueries = false;
	private HibernateTemplate hibernateTemplate;
	
	/** 
	 * Synchronized map with CriteriaBuilders by name
	 */
	private Map<String, CriteriaBuilder> criteriaBuilderMap = 
		Collections.synchronizedMap(new HashMap<String, CriteriaBuilder>());
	

	public HibernateDao() {
	}
	/**
	 * @param persistentClass
	 */
	public HibernateDao(Class<T> persistentClass) {
		this.entityClass = persistentClass;
	}

	/**
	 * Get Page, apply filter if any.
	 * If Filter is a entity model, use Example to create a criteria.
	 * else enable filter by name on session. 
	 * @param page with page definitions
	 * @return page of results
	 */
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public <K> Page<K> getPage(Page<K> page) {
		
		List data = null;
		// try named query
		Query query = getQuery(page);
		if (query != null) {
			data = query.list();
		}
		else {
			// try filter, example and criteria builders
			Criteria criteria = getCriteria(page);
			ResultTransformer rt = ((CriteriaImpl) criteria).getResultTransformer(); 
			criteria.setProjection(Projections.rowCount());
			 page.setCount(((Long) criteria.uniqueResult()).intValue());
			// reset criteria
			criteria.setProjection(null);
			criteria.setResultTransformer(rt);
			// set start index and page size
			criteria.setFirstResult(page.getStartIndex())
				.setMaxResults(page.getPageSize());
			applyOrder(page, criteria);
			// run it
			criteria.setCacheable(cachePageQueries);
			data = criteria.list();
		}
		
		page.setData(data);
		
		return page;
	}

	/**
	 * Get Hibernate named Query and configure with filter from page. 
	 * Set the result count on page also. 
	 * @param page page 
	 * @return Hibernate named Query.
	 */
	private Query getQuery(Page<?> page) {
		Object filter = page.getFilter();
		
		try {
			if (filter instanceof Filter) {
				Filter f = (Filter) filter;
				Query query = getSession().getNamedQuery(f.getFilterName());
				Query countQuery = getSession().createQuery(query.getQueryString().replaceFirst("select", "count"));
				query.setProperties(f.getParameterMap());
				query.setMaxResults(page.getPageSize());
				query.setFirstResult(page.getStartIndex());
				page.setCount((Integer) countQuery.uniqueResult());

				return query;
			}
		}
		catch (HibernateException e) {}
		return null;
	}

	/**
	 * Create a Criteria from filter. If filter is a entity class instance, 
	 * return a Criteria with a Example Criterion applied.
	 * If not try four options in order:
	 * 
	 * 	1. if there are a filter with name filter.getName() enable it and return criteria
	 *  2. if there are a criteria builder with name filter.getName() use it to create Critera.
	 *  4. if there are a method named createCritera + filter.getName() invoke it to create Criteria
	 *  5. Return a Criteria for this entity class without Criterion applied.
	 * 
	 * @param filter the filter
	 * @return a new Criteria 
	 */
	protected Criteria getCriteria(Page<?> page) {
		Criteria executableCriteria = getSession().createCriteria(
				getEntityClass());
		Object filter = page.getFilter();
		// apply filter, if any
		if (filter != null) {
			if (ClassUtils.isAssignable(getEntityClass(), filter.getClass())) {
				// try a findByExample...
				executableCriteria.add(Example.create(getEntityClass()));
			} 
			else if (filter instanceof 	Filter) {
				Filter f = (Filter) filter;

				if (!enableFilter(f)) {
					if (log.isDebugEnabled()) 
						log.debug("No hibernate filter found with name: " + f.getFilterName() +
								", try criteria builder.");
					// if no filter, try criteria builder
					if (criteriaBuilderMap.containsKey(f.getFilterName())) {
						CriteriaBuilder cb =  criteriaBuilderMap.get(f.getFilterName());
						if(log.isDebugEnabled())
							log.debug("Found criteria builder with name: " + f.getFilterName() + 
								" - criteria builder class: " + cb.getClass().getSimpleName());
						executableCriteria = cb.build(executableCriteria, f);
					}
					// if no criteria builder try subclass method
					else if (ClassUtils.hasMethod(getClass(), "createCriteria" + f.getFilterName(), new Class[] {Criteria.class})) {
						Method method  = ClassUtils.getMethodIfAvailable(
								getClass(), "createCriteria" + f.getFilterName(), new Class[] {Criteria.class});
						if (method != null) {
							try {
								executableCriteria = (Criteria) method.invoke(this, executableCriteria);
							} catch (Exception e) { 
								log.error(e); 
							}
						}
					}
				}
			}
			else {
				log.warn("Cannot manage filter of type: " + filter.getClass());
			}
		}
		
		return executableCriteria;
	}

	/**
	 * Enable predefined filter in current session
	 * @param f Filter with filter name and parameters
	 * @return true if hibernate filter exists
	 */
	public boolean enableFilter(Filter f) {
		if (getSessionFactory().getDefinedFilterNames().contains(f.getFilterName())) {
			org.hibernate.Filter hf = getSession().enableFilter(f.getFilterName());
			Map<String, Object> parameterMap = f.getParameterMap();
			for (String key :parameterMap.keySet()) {
				hf.setParameter(key, parameterMap.get(key));
			}
			return true;
		}
		
		return false;
	}

	/**
	 * @return ClassMetadata from entityClass
	 */
	private ClassMetadata getClassMetadata() {
		return getClassMetadata(getEntityClass());
	}
	
	/**
	 * return ClassMetadata from Class
	 * @param clazz the class
	 * @return the ClassMetadata
	 */
	private ClassMetadata getClassMetadata(Class<?> clazz) {
		return getHibernateTemplate().getSessionFactory().
			getClassMetadata(clazz);
	}
	
	/**
	 * Apply Order to Criteria
	 * @param page the page
	 * @param criteria the criteria
	 */
	protected void applyOrder(Page<?> page, Criteria criteria) {
		Order order = createOrder(criteria, page.getSortName(), Page.Order.ASC.equals(page.getOrder()));
		if (order != null) 
			criteria.addOrder(order);
	}

	/** 
	 * Create Order from criteria and property path
	 * @param criteria the hibernate criteria to apply order on
	 * @param propertyPath the property path
	 * @return Order 
	 */
	protected Order createOrder(Criteria criteria, String propertyPath, boolean ascending) {
		Order order = null;
		
		if (propertyPath != null) {
			String sortProperty = PropertyUtils.getPropertyName(propertyPath);
			try {
				if (PropertyUtils.isNested(propertyPath)) {
					String alias = PropertyUtils.getPropertyName(PropertyUtils.getPath(propertyPath));
					// Need to create alias?
					// String alias = HibernateUtils.findAliasForPropertyPath(criteria, propertyPath);
					HibernateUtils.createAlias(criteria, PropertyUtils.getPath(propertyPath));
					sortProperty = alias + PropertyUtils.PROPERTY_SEPARATOR + sortProperty;
				}
				else { // test if property is an entity class
					Type sortType = getClassMetadata().getPropertyType(propertyPath);
					if (sortType.isEntityType()) { // is entity, look for 'name' property
						String[] propertyNames = getClassMetadata(sortType.getReturnedClass()).getPropertyNames();
						for (String name : propertyNames) {
							if ("name".equals(name)) {
								log.info("Found property name on persistent class: " + sortType.getName());
								String newPath = propertyPath + PropertyAccessor.NESTED_PROPERTY_SEPARATOR + "name";
								return createOrder(criteria, newPath, ascending);
							}
						}
					}
				}

				if (log.isDebugEnabled())
					log.debug("Setting order as: " + sortProperty);

				order = ascending ? Order.asc(sortProperty) : Order.desc(sortProperty);
			}
			catch(HibernateException he) {
				log.error("Cannot to create Order for property: " + sortProperty + " for " +
						getEntityClass().getSimpleName(), he);
			}
		}
		else {
			// add default order by id
			ClassMetadata metadata = getClassMetadata();
			if (metadata != null)
				order = Order.asc(metadata.getIdentifierPropertyName());
		}
	
		return order;
		
	}
	
	/**
	 * @return the entityClass
	 */
	public Class<T> getEntityClass() {
		return entityClass;
	}

	/**
	 * @param entityClass the entityClass to set
	 */
	public void setEntityClass(Class<T> entityClass) {
		this.entityClass = entityClass;
	}
	
	/** 
	 * Delete a entity from db
	 * @param entity
	 */
	public void delete(T entity) {
		getHibernateTemplate().delete(entity);
	}
	
	public T save(T entity) {
		getHibernateTemplate().saveOrUpdate(entity);
		return entity;
	}

	
	@SuppressWarnings("unchecked")
	public List<Serializable> getKeys(Page<T> page) {
		Criteria criteria = getCriteria(page);
		return criteria.setProjection(Projections.id()).list();
		
	}

	public void deleteById(PK id) {
		getSession().delete(get(id));
	}
	
	public  T initialize(T entity) {
		getSession().buildLockRequest(LockOptions.NONE).lock(entity);
		HibernateUtils.initialize(getSessionFactory(), entity);
		return entity;
	}
	
	public T initialize(T entity, int depth) {
		getSession().buildLockRequest(LockOptions.NONE).lock(entity);
		HibernateUtils.initialize(getSessionFactory(), entity, depth);

		return entity;
	}

	/**
	 * @return the criteriaBuilderMap
	 */
	public Map<String, CriteriaBuilder> getCriteriaBuilderMap() {
		return criteriaBuilderMap;
	}

	/**
	 * @param criteriaBuilderMap the criteriaBuilderMap to set
	 */
	public void setCriteriaBuilderMap(
			Map<String, CriteriaBuilder> criteriaBuilderMap) {
		this.criteriaBuilderMap.clear();
		this.criteriaBuilderMap.putAll(criteriaBuilderMap);
	}

	/**
     * {@inheritDoc}
     */
    public List<T> getAll() {
        return new ArrayList<T>(getHibernateTemplate().loadAll(this.entityClass));
    }
    
    public List<T> getAllDistinct() {
        Collection<T> result = new LinkedHashSet<T>(getAll());
        return new ArrayList<T>(result);
    }
    
    /** 
     * {@inheritDoc}
     */
    @SuppressWarnings("unchecked")
    public List<T> findByNamedQuery(
        String queryName, 
        Map<String, Object> queryParams) {
        String []params = new String[queryParams.size()];
        Object []values = new Object[queryParams.size()];
        int index = 0;
        Iterator<String> i = queryParams.keySet().iterator();
        while (i.hasNext()) {
            String key = i.next();
            params[index] = key;
            values[index++] = queryParams.get(key);
        }
        return (List<T>) getHibernateTemplate().findByNamedQueryAndNamedParam(
            queryName, 
            params, 
            values);
    }
    
    /**
     * {@inheritDoc}
     */
    public T get(PK id) {
        T entity = (T) getHibernateTemplate().get(this.entityClass, id);

        if (entity == null) {
            log.warn("'" + this.entityClass.getSimpleName() + "' object with id '" + id + "' not found...");
            throw new ObjectRetrievalFailureException(this.entityClass, id);
        }

        return entity;
    }

    /**
     * {@inheritDoc}
     */
    public boolean exists(PK id) {
      return getHibernateTemplate().get(this.entityClass, id) != null;
    }

    /**
     * Count rows
     * @return number of rows
     */
    public int count() {
		return ((Long) getSession().createCriteria(entityClass)
			.setProjection(Projections.rowCount())
			.uniqueResult()).intValue();
	}
    
	/**
	 * @return the cachePageQueries
	 */
	public boolean isCachePageQueries() {
		return cachePageQueries;
	}
	
	/**
	 * @param cachePageQueries the cachePageQueries to set
	 */
	public void setCachePageQueries(boolean cachePageQueries) {
		this.cachePageQueries = cachePageQueries;
	}
	
	/**
	 * {@inheritDoc}
	 */
	@SuppressWarnings("unchecked")
	public <E> E get(PK id, Class<E> clazz) {
		return (E) getSession().get(clazz, id);
	}
	
	/**
	 * {@inheritDoc}
	 */
	@SuppressWarnings("unchecked")
	public <E> List<E> getAll(Class<E> clazz) {
		return getSession().createCriteria(clazz).list();
	}
	
	
	public HibernateTemplate getHibernateTemplate() {
		return hibernateTemplate;
	}
	
	public void setHibernateTemplate(HibernateTemplate hibernateTemplate) {
		this.hibernateTemplate = hibernateTemplate;
	}

	public final void setSessionFactory(SessionFactory sessionFactory) {
		if (this.hibernateTemplate == null || sessionFactory != this.hibernateTemplate.getSessionFactory()) {
			this.hibernateTemplate = new HibernateTemplate(sessionFactory);
		}
	}
	
	/**
	 * Return the Hibernate SessionFactory
	 */
	public final SessionFactory getSessionFactory() {
		return (this.hibernateTemplate != null ? this.hibernateTemplate.getSessionFactory() : null);
	}
	
	/**
	 * Return current hibernate Session from Hibernate template
	 * @return curren hibernate Session
	 */
	public Session getSession() {
		return hibernateTemplate.getSessionFactory().getCurrentSession();
	}
}