/**
 * This Source Code Form is subject to the terms of the Mozilla Public License,
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
 * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
 * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
 *
 * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
 * graphic logo is a trademark of OpenMRS Inc.
 */
package org.openmrs.api.db.hibernate;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.Criteria;
import org.hibernate.SessionFactory;
import org.hibernate.criterion.Conjunction;
import org.hibernate.criterion.CriteriaSpecification;
import org.hibernate.criterion.Criterion;
import org.hibernate.criterion.LogicalExpression;
import org.hibernate.criterion.MatchMode;
import org.hibernate.criterion.Order;
import org.hibernate.criterion.Restrictions;
import org.hibernate.criterion.SimpleExpression;
import org.hibernate.type.StringType;
import org.openmrs.PatientIdentifierType;
import org.openmrs.api.AdministrationService;
import org.openmrs.api.context.Context;
import org.openmrs.util.OpenmrsConstants;

/**
 * The PatientSearchCriteria class. It has API to return a criteria from the Patient Name and
 * identifier.
 *
 * @deprecated since 2.1.0 (in favor of Hibernate Search)
 */
@Deprecated
public class PatientSearchCriteria {
	
	private final static Log log = LogFactory.getLog(PatientSearchCriteria.class);
	
	private final SessionFactory sessionFactory;
	
	private final Criteria criteria;
	
	private PersonSearchCriteria personSearchCriteria;
	
	/**
	 * @param sessionFactory
	 * @param criteria
	 */
	public PatientSearchCriteria(SessionFactory sessionFactory, Criteria criteria) {
		this.sessionFactory = sessionFactory;
		this.criteria = criteria;
		this.personSearchCriteria = new PersonSearchCriteria();
	}
	
	/**
	 * Prepare a hibernate criteria for searching patients by name and/or identifier.
	 *
	 * The visibility of this method remains public in order not to break OpenMRS modules that use this method.
	 *
	 * Instead of calling this method consider using {@link org.openmrs.api.PatientService} or
	 * {@link org.openmrs.api.db.PatientDAO}.
	 *
	 * @param name
	 * @param identifier
	 * @param identifierTypes
	 * @param matchIdentifierExactly
	 * @param searchOnNamesOrIdentifiers specifies if the logic should find patients that match the
	 *            name or identifier otherwise find patients that match both the name and identifier
	 * @return {@link Criteria}
	 */
	public Criteria prepareCriteria(String name, String identifier, List<PatientIdentifierType> identifierTypes,
	        boolean matchIdentifierExactly, boolean orderByNames, boolean searchOnNamesOrIdentifiers) {
		
		PatientSearchMode patientSearchMode = getSearchMode(name, identifier, identifierTypes, searchOnNamesOrIdentifiers);
		
		switch (patientSearchMode) {
			case PATIENT_SEARCH_BY_NAME:
				addAliasForName(criteria, orderByNames);
				criteria.add(prepareCriterionForName(name));
				break;
			
			case PATIENT_SEARCH_BY_IDENTIFIER:
				addAliasForIdentifiers(criteria);
				criteria.add(prepareCriterionForIdentifier(identifier, identifierTypes, matchIdentifierExactly));
				break;
			
			case PATIENT_SEARCH_BY_NAME_OR_IDENTIFIER:

				// If only name *or* identifier is provided as a search parameter,
				// the respective value is copied to the empty search parameter.
				//
				// As a consequence, the *single* parameter is used to search for both names and identifiers.
				//
				name = copySearchParameter(identifier, name);
				identifier = copySearchParameter(name, identifier);
				
				addAliasForName(criteria, orderByNames);
				addAliasForIdentifiers(criteria);
				criteria.add(Restrictions.disjunction().add(prepareCriterionForName(name)).add(
				    prepareCriterionForIdentifier(identifier, identifierTypes, matchIdentifierExactly)));
				break;
			
			case PATIENT_SEARCH_BY_NAME_AND_IDENTIFIER:
				addAliasForName(criteria, orderByNames);
				addAliasForIdentifiers(criteria);
				criteria.add(prepareCriterionForName(name));
				criteria.add(prepareCriterionForIdentifier(identifier, identifierTypes, matchIdentifierExactly));
				break;
			
			default:
				break;
		}
		
		criteria.add(Restrictions.eq("voided", false));
		criteria.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY);
		
		log.debug(criteria.toString());
		
		return criteria;
	}
	
	/**
	 * Provides a Hibernate criteria object for searching patients by name, identifier or searchable attribute.
	 *
	 * The visibility of this method is "default" as this method should NOT be called directly by classes other
	 * than org.openmrs.api.db.hibernate.HibernatePatientDAO.
	 *
	 * Instead of calling this method consider using {@link org.openmrs.api.PatientService} or
	 * {@link org.openmrs.api.db.PatientDAO}.
	 *
	 * @param query defines search parameters
	 * @param includeVoided true/false whether or not to included voided patients
	 * @return criteria for searching by name OR identifier OR searchable attributes
	 */
	Criteria prepareCriteria(String query, boolean includeVoided) {
		addAliasForName(criteria, true);
		personSearchCriteria.addAliasForAttribute(criteria);
		addAliasForIdentifiers(criteria);
		criteria.add(Restrictions.disjunction().add(prepareCriterionForName(query, includeVoided)).add(
		    prepareCriterionForAttribute(query, includeVoided)).add(
		    prepareCriterionForIdentifier(query, new ArrayList<PatientIdentifierType>(), false, includeVoided)));
		if (!includeVoided) {
			criteria.add(Restrictions.eq("voided", false));
		}
		criteria.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY);
		log.debug(criteria.toString());
		return criteria;
	}
	
	/**
	 * Provides a Hibernate criteria object for searching patients by name, identifier or searchable attribute.
	 *
	 * The visibility of this method is "default" as this method should NOT be called directly by classes other
	 * than org.openmrs.api.db.hibernate.HibernatePatientDAO.
	 *
	 * Instead of calling this method consider using {@link org.openmrs.api.PatientService} or
	 * {@link org.openmrs.api.db.PatientDAO}.
	 *
	 * @param query defines search parameters
	 * @return criteria for searching by name OR identifier OR searchable attributes
	 */
	Criteria prepareCriteria(String query) {
		return prepareCriteria(query, false);
	}
	
	/**
	 * @param query defines search parameters
	 * @param matchExactly
	 * @param orderByNames
	 * @param includeVoided true/false whether or not to included voided patients
	 * @return criteria for searching by name OR identifier OR searchable attributes
	 */
	Criteria prepareCriteria(String query, Boolean matchExactly, boolean orderByNames, boolean includeVoided) {
		addAliasForName(criteria, orderByNames);
		
		if (matchExactly == null) {
			criteria.add(Restrictions.conjunction().add(prepareCriterionForName(query, null, includeVoided)).add(
			    Restrictions.not(prepareCriterionForName(query, true, includeVoided))).add(
			    Restrictions.not(prepareCriterionForName(query, false, includeVoided))));
		} else if (!matchExactly) {
			criteria.add(prepareCriterionForName(query, false, includeVoided));
		} else {
			personSearchCriteria.addAliasForAttribute(criteria);
			addAliasForIdentifiers(criteria);
			
			criteria.add(Restrictions.disjunction().add(prepareCriterionForName(query, true, includeVoided)).add(
			    prepareCriterionForAttribute(query, includeVoided)).add(
			    prepareCriterionForIdentifier(query, new ArrayList<PatientIdentifierType>(), false, includeVoided)));
		}
		
		if (!includeVoided) {
			criteria.add(Restrictions.eq("voided", false));
		}
		criteria.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY);
		
		log.debug(criteria.toString());
		
		return criteria;
	}
	
	/**
	 * @should return source value when target is blank
	 * @should return target value when target is non-blank
	 */
	String copySearchParameter(String source, String target) {
		if (!StringUtils.isBlank(source) && StringUtils.isBlank(target)) {
			return source;
		}
		return target;
	}
	
	/**
	 * @should identify search by name
	 * @should identify search by identifier
	 * @should identify search by identifier type list
	 * @should identify search by identifier and identifier type list
	 * @should identify search by name or identifier
	 * @should identify search by name and identifier
	 */
	PatientSearchMode getSearchMode(String name, String identifier, List<PatientIdentifierType> identifierTypes,
	        boolean searchOnNamesOrIdentifiers) {
		if (searchOnNamesOrIdentifiers) {
			return PatientSearchMode.PATIENT_SEARCH_BY_NAME_OR_IDENTIFIER;
		}
		
		if (!StringUtils.isBlank(name) && StringUtils.isBlank(identifier) && CollectionUtils.isEmpty(identifierTypes)) {
			return PatientSearchMode.PATIENT_SEARCH_BY_NAME;
		}
		
		// de Morgan's law coming to fruition: (!A||!B) <=> !(A&&B)
		//
		if (StringUtils.isBlank(name) && !(StringUtils.isBlank(identifier) && CollectionUtils.isEmpty(identifierTypes))) {
			return PatientSearchMode.PATIENT_SEARCH_BY_IDENTIFIER;
		}
		
		return PatientSearchMode.PATIENT_SEARCH_BY_NAME_AND_IDENTIFIER;
	}
	
	private void addAliasForName(Criteria criteria, boolean orderByNames) {
		criteria.createAlias("names", "name");
		if (orderByNames) {
			criteria.addOrder(Order.asc("name.givenName"));
			criteria.addOrder(Order.asc("name.middleName"));
			criteria.addOrder(Order.asc("name.familyName"));
		}
	}
	
	private void addAliasForIdentifiers(Criteria criteria) {
		criteria.createAlias("identifiers", "ids", CriteriaSpecification.LEFT_JOIN);
	}
	
	/**
	 * Utility method to add identifier expression to an existing criteria
	 *
	 * @param identifier
	 * @param identifierTypes
	 * @param matchIdentifierExactly
	 * @param includeVoided true/false whether or not to included voided patients
	 */
	private Criterion prepareCriterionForIdentifier(String identifier, List<PatientIdentifierType> identifierTypes,
	        boolean matchIdentifierExactly, boolean includeVoided) {
		
		identifier = HibernateUtil.escapeSqlWildcards(identifier, sessionFactory);
		Conjunction conjunction = Restrictions.conjunction();
		
		if (!includeVoided) {
			conjunction.add(Restrictions.eq("ids.voided", false));
		}
		// do the identifier restriction
		if (identifier != null) {
			// if the user wants an exact search, match on that.
			if (matchIdentifierExactly) {
				SimpleExpression matchIdentifier = Restrictions.eq("ids.identifier", identifier);
				if (Context.getAdministrationService().isDatabaseStringComparisonCaseSensitive()) {
					matchIdentifier.ignoreCase();
				}
				conjunction.add(matchIdentifier);
			} else {
				AdministrationService adminService = Context.getAdministrationService();
				String regex = adminService.getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_REGEX, "");
				String patternSearch = adminService.getGlobalProperty(
				    OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_SEARCH_PATTERN, "");
				
				// remove padding from identifier search string
				if (Pattern.matches("^\\^.{1}\\*.*$", regex)) {
					identifier = removePadding(identifier, regex);
				}
				
				if (org.springframework.util.StringUtils.hasLength(patternSearch)) {
					conjunction.add(splitAndGetSearchPattern(identifier, patternSearch));
				}
				// if the regex is empty, default to a simple "like" search or if
				// we're in hsql world, also only do the simple like search (because
				// hsql doesn't know how to deal with 'regexp'
				else if ("".equals(regex) || HibernateUtil.isHSQLDialect(sessionFactory)) {
					conjunction.add(getCriterionForSimpleSearch(identifier, adminService));
				}
				// if the regex is present, search on that
				else {
					regex = replaceSearchString(regex, identifier);
					conjunction.add(Restrictions.sqlRestriction("identifier regexp ?", regex, StringType.INSTANCE));
				}
			}
		}
		
		// TODO add a junit test for patientIdentifierType restrictions	
		
		// do the type restriction
		if (!CollectionUtils.isEmpty(identifierTypes)) {
			criteria.add(Restrictions.in("ids.identifierType", identifierTypes));
		}
		
		return conjunction;
	}
	
	/**
	 * Utility method to add identifier expression to an existing criteria
	 *
	 * @param identifier
	 * @param identifierTypes
	 * @param matchIdentifierExactly
	 */
	private Criterion prepareCriterionForIdentifier(String identifier, List<PatientIdentifierType> identifierTypes,
	        boolean matchIdentifierExactly) {
		return prepareCriterionForIdentifier(identifier, identifierTypes, matchIdentifierExactly, false);
	}
	
	/**
	 * Utility method to add prefix and suffix like expression
	 *
	 * @param identifier
	 * @param adminService
	 */
	private Criterion getCriterionForSimpleSearch(String identifier, AdministrationService adminService) {
		String prefix = adminService.getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_PREFIX, "");
		String suffix = adminService.getGlobalProperty(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_IDENTIFIER_SUFFIX, "");
		StringBuilder likeString = new StringBuilder(prefix).append(identifier).append(suffix);
		return Restrictions.ilike("ids.identifier", likeString.toString());
	}
	
	/**
	 * Utility method to add search pattern expression to identifier.
	 *
	 * @param identifier
	 * @param patternSearch
	 */
	private Criterion splitAndGetSearchPattern(String identifier, String patternSearch) {
		// split the pattern before replacing in case the user searched on a comma
		List<String> searchPatterns = new ArrayList<String>();
		// replace the @SEARCH@, etc in all elements
		for (String pattern : patternSearch.split(",")) {
			searchPatterns.add(replaceSearchString(pattern, identifier));
		}
		return Restrictions.in("ids.identifier", searchPatterns);
	}
	
	/**
	 * Utility method to remove padding from the identifier.
	 *
	 * @param identifier
	 * @param regex
	 * @return identifier without the padding.
	 */
	private String removePadding(String identifier, String regex) {
		String padding = regex.substring(regex.indexOf("^") + 1, regex.indexOf("*"));
		Pattern pattern = Pattern.compile("^" + padding + "+");
		identifier = pattern.matcher(identifier).replaceFirst("");
		return identifier;
	}
	
	/**
	 * Utility method to add name expressions to criteria.
	 *
	 * @param name
	 * @param matchExactly
	 * @param includeVoided true/false whether or not to included voided patients
	 */
	private Criterion prepareCriterionForName(String name, Boolean matchExactly, boolean includeVoided) {
		name = HibernateUtil.escapeSqlWildcards(name, sessionFactory);
		
		Conjunction conjunction = Restrictions.conjunction();
		String[] nameParts = getQueryParts(name);
		if (nameParts.length > 0) {
			StringBuilder multiName = new StringBuilder(nameParts[0]);
			
			for (int i = 0; i < nameParts.length; i++) {
				String singleName = nameParts[i];
				
				if (singleName != null && singleName.length() > 0) {
					Criterion singleNameCriterion = getCriterionForName(singleName, matchExactly, includeVoided);
					Criterion criterion = singleNameCriterion;
					
					if (i > 0) {
						multiName.append(" ");
						multiName.append(singleName);
						Criterion multiNameCriterion = getCriterionForName(multiName.toString(), matchExactly, includeVoided);
						criterion = Restrictions.or(singleNameCriterion, multiNameCriterion);
					}
					
					conjunction.add(criterion);
				}
			}
		}
		
		return conjunction;
	}
	
	/**
	 * Utility method to add name expressions to criteria.
	 *
	 * @param name
	 * @param includeVoided true/false whether or not to included voided patients
	 */
	private Criterion prepareCriterionForName(String name, boolean includeVoided) {
		return prepareCriterionForName(name, null, includeVoided);
	}
	
	/**
	 * Utility method to add name expressions to criteria.
	 *
	 * @param name
	 */
	private Criterion prepareCriterionForName(String name) {
		return prepareCriterionForName(name, null, false);
	}
	
	/**
	 * @should process simple space as separator
	 * @should process comma as separator
	 * @should process mixed separators
	 * @should not return empty name parts
	 * @should reject null as name
	 **/
	String[] getQueryParts(String query) {
		if (query == null) {
			throw new IllegalArgumentException("query must not be null");
		}
		
		query = query.replace(",", " ");
		String[] queryPartArray = query.split(" ");
		
		List<String> queryPartList = new ArrayList<String>();
		for (String queryPart : queryPartArray) {
			if (queryPart.trim().length() > 0) {
				queryPartList.add(queryPart);
			}
		}
		
		return queryPartList.toArray(new String[0]);
	}
	
	/**
	 * Returns a criteria object comparing the given string to each part of the name. <br>
	 * <br>
	 * This criteria is essentially:
	 * <p>
	 *
	 * <pre>
	 * ... where voided = false &amp;&amp; name in (familyName2, familyName, middleName, givenName)
	 * </pre>
	 *
	 * Except when the name provided is less than min characters (usually 3) then we will look for
	 * an EXACT match by default
	 *
	 * @param name
	 * @param matchExactly
	 * @param includeVoided true/false whether or not to included voided patients
	 * @return {@link LogicalExpression}
	 */
	private Criterion getCriterionForName(String name, Boolean matchExactly, boolean includeVoided) {
		if (isShortName(name)) {
			return getCriterionForShortName(name, includeVoided);
		} else {
			if (matchExactly != null) {
				if (matchExactly) {
					return getCriterionForShortName(name, includeVoided);
				}
				return getCriterionForNoExactName(name, includeVoided);
			}
			return getCriterionForLongName(name, includeVoided);
		}
	}
	
	/**
	 * @should recognise short name
	 * @should recognise long name
	 */
	Boolean isShortName(String name) {
		Integer minChars = Context.getAdministrationService().getGlobalPropertyValue(
		    OpenmrsConstants.GLOBAL_PROPERTY_MIN_SEARCH_CHARACTERS,
		    OpenmrsConstants.GLOBAL_PROPERTY_DEFAULT_MIN_SEARCH_CHARACTERS);
		
		if (name != null && name.length() < minChars) {
			return Boolean.TRUE;
			
		} else {
			return Boolean.FALSE;
		}
	}
	
	private Criterion getCriterionForShortName(String name, boolean includeVoided) {
		Criterion criterion = Restrictions.disjunction().add(
		    Restrictions.conjunction().add(Restrictions.isNotNull("name.givenName")).add(
		        Restrictions.eq("name.givenName", name).ignoreCase())).add(
		    Restrictions.conjunction().add(Restrictions.isNotNull("name.middleName")).add(
		        Restrictions.eq("name.middleName", name).ignoreCase())).add(
		    Restrictions.conjunction().add(Restrictions.isNotNull("name.familyName")).add(
		        Restrictions.eq("name.familyName", name).ignoreCase())).add(
		    Restrictions.conjunction().add(Restrictions.isNotNull("name.familyName2")).add(
		        Restrictions.eq("name.familyName2", name).ignoreCase()));
		
		if (!includeVoided) {
			return Restrictions.conjunction().add(Restrictions.eq("name.voided", false)).add(criterion);
		}
		return criterion;
	}
	
	private Criterion getCriterionForLongName(String name, boolean includeVoided) {
		MatchMode matchMode = getMatchMode();
		Criterion criterion = Restrictions.disjunction().add(Restrictions.like("name.givenName", name, matchMode)).add(
		    Restrictions.like("name.middleName", name, matchMode))
		        .add(Restrictions.like("name.familyName", name, matchMode)).add(
		            Restrictions.like("name.familyName2", name, matchMode));
		
		if (!includeVoided) {
			return Restrictions.conjunction().add(Restrictions.eq("name.voided", false)).add(criterion);
		}
		return criterion;
	}
	
	private Criterion getCriterionForNoExactName(String name, boolean includeVoided) {
		MatchMode matchMode = getMatchMode();
		
		Criterion criterion = Restrictions.conjunction().add(
		    Restrictions.disjunction().add(
		        Restrictions.conjunction().add(Restrictions.isNotNull("name.givenName")).add(
		            Restrictions.like("name.givenName", name, matchMode))).add(
		        Restrictions.conjunction().add(Restrictions.isNotNull("name.middleName")).add(
		            Restrictions.like("name.middleName", name, matchMode))).add(
		        Restrictions.conjunction().add(Restrictions.isNotNull("name.familyName")).add(
		            Restrictions.like("name.familyName", name, matchMode))).add(
		        Restrictions.conjunction().add(Restrictions.isNotNull("name.familyName2")).add(
		            Restrictions.like("name.familyName2", name, matchMode)))).add(
		    Restrictions.disjunction().add(Restrictions.isNull("name.givenName")).add(
		        Restrictions.ne("name.givenName", name))).add(
		    Restrictions.disjunction().add(Restrictions.isNull("name.middleName")).add(
		        Restrictions.ne("name.middleName", name))).add(
		    Restrictions.disjunction().add(Restrictions.isNull("name.familyName")).add(
		        Restrictions.ne("name.familyName", name))).add(
		    Restrictions.disjunction().add(Restrictions.isNull("name.familyName2")).add(
		        Restrictions.ne("name.familyName2", name)));
		
		if (!includeVoided) {
			return Restrictions.conjunction().add(Restrictions.eq("name.voided", false)).add(criterion);
		}
		return criterion;
	}
	
	/**
	 * @should return start as default match mode
	 * @should return start as configured match mode
	 * @should return anywhere as configured match mode
	 */
	MatchMode getMatchMode() {
		String matchMode = Context.getAdministrationService().getGlobalProperty(
		    OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_MODE,
		    OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_START);
		
		if (matchMode.equalsIgnoreCase(OpenmrsConstants.GLOBAL_PROPERTY_PATIENT_SEARCH_MATCH_ANYWHERE)) {
			return MatchMode.ANYWHERE;
		}
		return MatchMode.START;
	}
	
	/**
	 * Puts @SEARCH@, @SEARCH-1@, and @CHECKDIGIT@ into the search string
	 *
	 * @param regex the admin-defined search string containing the @..@'s to be replaced
	 * @param identifierSearched the user entered search string
	 * @return substituted search strings.
	 */
	private String replaceSearchString(String regex, String identifierSearched) {
		String returnString = regex.replaceAll("@SEARCH@", identifierSearched);
		if (identifierSearched.length() > 1) {
			// for 2 or more character searches, we allow regex to use last character as check digit
			returnString = returnString.replaceAll("@SEARCH-1@", identifierSearched.substring(0,
			    identifierSearched.length() - 1));
			returnString = returnString.replaceAll("@CHECKDIGIT@", identifierSearched
			        .substring(identifierSearched.length() - 1));
		} else {
			returnString = returnString.replaceAll("@SEARCH-1@", "");
			returnString = returnString.replaceAll("@CHECKDIGIT@", "");
		}
		return returnString;
	}
	
	private Criterion prepareCriterionForAttribute(String query, boolean includeVoided) {
		query = HibernateUtil.escapeSqlWildcards(query, sessionFactory);
		
		Conjunction conjunction = Restrictions.conjunction();
		MatchMode matchMode = personSearchCriteria.getAttributeMatchMode();
		
		String[] queryParts = getQueryParts(query);
		for (String queryPart : queryParts) {
			conjunction.add(personSearchCriteria.prepareCriterionForAttribute(queryPart, includeVoided, matchMode));
		}
		
		return conjunction;
	}
}
