/*
 * JBoss, Home of Professional Open Source.
 * See the COPYRIGHT.txt file distributed with this work for information
 * regarding copyright ownership.  Some portions may be licensed
 * to Red Hat, Inc. under one or more contributor license agreements.
 * 
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 * 
 * This library 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
 * Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301 USA.
 */

/**
 * 
 * Please see the user's guide for a full description of capabilties, etc.
 * 
 * Description/Assumptions:
 * 1. Table's name in source defines the base DN (or context) for the search.
 * Example: Table.NameInSource=ou=people,dc=gene,dc=com
 * [Optional] The table's name in source can also define a search scope. Append
 * a "?" character as a delimiter to the base DN, and add the search scope string. 
 * The following scopes are available:
 * SUBTREE_SCOPE
 * ONELEVEL_SCOPE
 * OBJECT_SCOPE
 * [Default] LDAPConnectorConstants.ldapDefaultSearchScope 
 * is the default scope used, if no scope is defined (currently, ONELEVEL_SCOPE).
 * 
 * 2. Column's name in source defines the LDAP attribute name.
 * [Default] If no name in source is defined, then we attempt to use the column name
 * as the LDAP attribute name.
 * 
 * 
 * TODO: Implement paged searches -- the LDAP server must support VirtualListViews.
 * TODO: Implement cancel.
 * TODO: Add Sun/Netscape implementation, AD/OpenLDAP implementation.
 * 
 * 
 * Note: 
 * Greater than is treated as >=
 * Less-than is treater as <=
 * If an LDAP entry has more than one entry for an attribute of interest (e.g. a select item), we only return the
 * first occurrance. The first occurance is not predictably the same each time, either, according to the LDAP spec.
 * If an attribute is not present, we return the empty string. Arguably, we could throw an exception.
 * 
 * Sun LDAP won't support Sort Orders for very large datasets. So, we've set the sorting to NONCRITICAL, which
 * allows Sun to ignore the sort order. This will result in the results to come back as unsorted, without any error.
 * 
 * Removed support for ORDER BY for two reasons:
 * 1: LDAP appears to have a limit to the number of records that 
 * can be server-side sorted. When the limit is reached, two things can happen:
 * a. If sortControl is set to CRITICAL, then the search fails.
 * b. If sortControl is NONCRITICAL, then the search returns, unsorted.
 * We'd like to support ORDER BY, no matter how large the size, so we turn it off,
 * and allow MetaMatrix to do it for us.
 * 2: Supporting ORDER BY appears to negatively effect the query plan
 * when cost analysis is used. We stop using dependent queries, and start
 * using inner joins.
 *
 */

package org.teiid.translator.ldap;

import java.io.IOException;
import java.lang.reflect.Array;
import java.sql.Timestamp;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;

import javax.naming.InvalidNameException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.SizeLimitExceededException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.Control;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.PagedResultsControl;
import javax.naming.ldap.PagedResultsResponseControl;
import javax.naming.ldap.Rdn;
import javax.naming.ldap.SortControl;
import javax.naming.ldap.SortKey;

import org.teiid.core.types.ArrayImpl;
import org.teiid.logging.LogConstants;
import org.teiid.logging.LogManager;
import org.teiid.metadata.Column;
import org.teiid.translator.ExecutionContext;
import org.teiid.translator.ResultSetExecution;
import org.teiid.translator.TranslatorException;
import org.teiid.translator.TypeFacility;



/** 
 * LDAPSyncQueryExecution is responsible for executing an LDAP search 
 * corresponding to a read-only "select" query.
 */
public class LDAPQueryExecution implements ResultSetExecution {

	static final String MULTIVALUED_CONCAT = "multivalued-concat"; //$NON-NLS-1$
	static final String delimiter = "?"; //$NON-NLS-1$
	
	private LDAPSearchDetails searchDetails;
	private LdapContext ldapCtx;
	private NamingEnumeration<?> searchEnumeration;
	private LDAPExecutionFactory executionFactory;
	private ExecutionContext executionContext;
	private SearchControls ctrls;
	private int resultCount;
	private Iterator<List<Object>> unwrapIterator;
	private int unwrapPos = -1;

	/** 
	 * Constructor
	 * @param executionMode the execution mode.
	 * @param ctx the execution context.
	 * @param logger the ConnectorLogger
	 * @param connection the LDAP Context
	 */
	public LDAPQueryExecution(LdapContext ldapContext,LDAPSearchDetails search, SearchControls searchControls, LDAPExecutionFactory factory,ExecutionContext context) {
		this.searchDetails = search;
		this.ldapCtx = ldapContext;
		this.ctrls = searchControls;
		this.executionFactory = factory;
		this.executionContext = context;
	}

	/** 
	 * method to execute the supplied query
	 * @param query the query object.
	 * @param maxBatchSize the max batch size.
	 */
	@Override
	public void execute() throws TranslatorException {
		String ctxName = this.searchDetails.getContextName();
		String filter = this.searchDetails.getContextFilter();
		if (ctxName == null || filter == null || this.ctrls == null) {
			throw new TranslatorException("Search context, filter, or controls were null. Cannot execute search."); //$NON-NLS-1$
		}

		ArrayList<Column> attributeList = searchDetails.getElementList();
		
		//determine if there is an array value to unwrap
		for (int i = 0; i < attributeList.size(); i++) {
			Column col = attributeList.get(i);
			if (Boolean.valueOf(col.getProperty(LDAPExecutionFactory.UNWRAP, false))) {
				if (unwrapPos > -1) {
					throw new TranslatorException(LDAPPlugin.Util.gs(LDAPPlugin.Event.TEIID12014, col, attributeList.get(unwrapPos)));
				}
				unwrapPos = i;
			}
		}
		
		setRequestControls(null);
		// Execute the search.
		executeSearch();
	}

	/** 
	 * Set the standard request controls
	 */
	private void setRequestControls(byte[] cookie) throws TranslatorException {
		List<Control> ctrl = new ArrayList<Control>();
		SortKey[] keys = searchDetails.getSortKeys();
		try {			
			if (keys != null) {
				ctrl.add(new SortControl(keys, Control.NONCRITICAL));
			}
			if (this.executionFactory.usePagination()) {
				ctrl.add(new PagedResultsControl(this.executionContext.getBatchSize(), cookie, Control.CRITICAL));
			}
			if (!ctrl.isEmpty()) {
				this.ldapCtx.setRequestControls(ctrl.toArray(new Control[ctrl.size()]));
				LogManager.logTrace(LogConstants.CTX_CONNECTOR, "Sort/pagination controls were created successfully."); //$NON-NLS-1$
			}
		} catch (NamingException ne) {
            final String msg = LDAPPlugin.Util.getString("LDAPSyncQueryExecution.setControlsError") +  //$NON-NLS-1$
            " : "+ne.getExplanation(); //$NON-NLS-1$
			throw new TranslatorException(ne, msg);
		} catch(IOException e) {
			throw new TranslatorException(e);
		}
	}

	/**
	 * Perform the LDAP search against the subcontext, using the filter and 
	 * search controls appropriate to the query and model metadata.
	 */
	private void executeSearch() throws TranslatorException {
		String filter = searchDetails.getContextFilter();
		try {
			searchEnumeration = this.ldapCtx.search("", filter, ctrls); //$NON-NLS-1$
		} catch (NamingException ne) {
            final String msg = LDAPPlugin.Util.getString("LDAPSyncQueryExecution.execSearchError"); //$NON-NLS-1$
			throw new TranslatorException(ne, msg + " : " + ne.getExplanation());  //$NON-NLS-1$ 
		} catch(Exception e) {
            final String msg = LDAPPlugin.Util.getString("LDAPSyncQueryExecution.execSearchError"); //$NON-NLS-1$
			throw new TranslatorException(e, msg); 
		}
	}

	// GHH 20080326 - attempt to implement cancel here.  First try to
	// close the searchEnumeration, then the search context.
	// We are very conservative when closing the enumeration
	// but less so when closing context, since it is safe to call close
	// on contexts multiple times
	@Override
	public void cancel() throws TranslatorException {
		close();
	}

	// GHH 20080326 - replaced existing implementation with the same
	// code as used by cancel method.  First try to
	// close the searchEnumeration, then the search context
	// We are very conservative when closing the enumeration
	// but less so when closing context, since it is safe to call close
	// on contexts multiple times
	@Override
	public void close() {
		if (searchEnumeration != null) {
			try {
				searchEnumeration.close();
			} catch (Exception e) { } // catch everything, because NamingEnumeration has undefined behavior if it previously hit an exception
		}
		if (ldapCtx != null) {
			try {
				ldapCtx.close();
			} catch (NamingException ne) {
	            LogManager.logWarning(LogConstants.CTX_CONNECTOR, LDAPPlugin.Util.gs(LDAPPlugin.Event.TEIID12003, ne.getExplanation()));
			}
		}
	}
	
	/**
	 * Fetch the next batch of data from the LDAP searchEnumerationr result.
	 * @return the next Batch of results.
	 */
	// GHH 20080326 - set all batches as last batch after an exception
	// is thrown calling a method on the enumeration.  Per Javadoc for
	// javax.naming.NamingEnumeration, enumeration is invalid after an
	// exception is thrown - by setting last batch indicator we prevent
	// it from being used again.
	// GHH 20080326 - also added return of explanation for generic
	// NamingException
	public List<?> next() throws TranslatorException {
		try {
			if (unwrapIterator != null) {
				if (unwrapIterator.hasNext()) {
					return unwrapIterator.next();
				}
				unwrapIterator = null;
			}
			// The search has been executed, so process up to one batch of
			// results.
			List<?> result = null;
			while (result == null && searchEnumeration != null && searchEnumeration.hasMore())
			{
				SearchResult searchResult = (SearchResult) searchEnumeration.next();
				try {
					result = getRow(searchResult);
				} catch (InvalidNameException e) {
					
				}
			}
			
			if (result == null && this.executionFactory.usePagination()) {
			    byte[] cookie = null;
				Control[] controls = ldapCtx.getResponseControls();
		        if (controls != null) {
		        	for (int i = 0; i < controls.length; i++) {
		        		if (controls[i] instanceof PagedResultsResponseControl) {
		        			PagedResultsResponseControl prrc = (PagedResultsResponseControl)controls[i];
		                    cookie = prrc.getCookie();
		        		}
		        	}
		        }
		        
		        if (cookie == null) {
		        	return null;
		        }
	
		        setRequestControls(cookie);
		        executeSearch();
		        return next();
			}

			if (result != null) {
				resultCount++;
			}
			return result;
		} catch (SizeLimitExceededException e) {
			if (resultCount != searchDetails.getCountLimit()) {
				String msg = LDAPPlugin.Util.gs(LDAPPlugin.Event.TEIID12008);
				TranslatorException te = new TranslatorException(e, msg);
				if (executionFactory.isExceptionOnSizeLimitExceeded()) {
					throw te;
				}
				this.executionContext.addWarning(te);
				LogManager.logWarning(LogConstants.CTX_CONNECTOR, e, msg); 
			}
			return null; // GHH 20080326 - if size limit exceeded don't try to read more results
		} catch (NamingException ne) {
			throw new TranslatorException(ne, LDAPPlugin.Util.gs("ldap_error")); //$NON-NLS-1$
		}
	}

	/**
	 * Create a row using the searchResult and add it to the supplied batch.
	 * @param batch the supplied batch
	 * @param result the search result
	 * @throws InvalidNameException 
	 */
	// GHH 20080326 - added fetching of DN of result, for directories that
	// do not include it as an attribute
	private List<?> getRow(SearchResult result) throws TranslatorException, InvalidNameException {
		Attributes attrs = result.getAttributes();
		ArrayList<Column> attributeList = searchDetails.getElementList();
		final List<Object> row = new ArrayList<Object>(attributeList.size());
		
		for (int i = 0; i < attributeList.size(); i++) {
			Column col = attributeList.get(i);
			Object val = getValue(col, result, attrs, i == unwrapPos);  // GHH 20080326 - added resultDN parameter to call
			row.add(val);
		}
		
		if (unwrapPos > -1) {
			Object toUnwrap = row.get(unwrapPos);
			if (toUnwrap == null) {
				return row; //missing value
			}
			if (toUnwrap instanceof ArrayImpl) {
				final Object[] val = ((ArrayImpl) toUnwrap).getValues();
				if (val.length == 0) {
					row.set(unwrapPos, null); //empty value
				} else {
					unwrapIterator = new Iterator<List<Object>>() {
						int i = 0;
						@Override
						public boolean hasNext() {
							return i < val.length;
						}
						@Override
						public List<Object> next() {
							List<Object> newRow = new ArrayList<Object>(row);
							newRow.set(unwrapPos, val[i++]);
							return newRow;
						}
						@Override
						public void remove() {
							
						}
					};
					if (unwrapIterator.hasNext()) {
						return unwrapIterator.next();
					}
				}
			}
		}
		return row;
	}

	/**
	 * Add Result to Row
	 * @param modelElement the model element
	 * @param attrs the attributes
	 * @param row the row
	 * @throws InvalidNameException 
	 */
	// GHH 20080326 - added resultDistinguishedName to method signature.  If
	// there is an element in the model named "DN" and there is no attribute
	// with this name in the search result, we return this new parameter
	// value for that column in the result
	// GHH 20080326 - added handling of ClassCastException when non-string
	// attribute is returned
	private Object getValue(Column modelElement, SearchResult result, Attributes attrs, boolean unwrap) throws TranslatorException, InvalidNameException {

		String modelAttrName = modelElement.getSourceName();
		Class<?> modelAttrClass = modelElement.getJavaType();
		
		String multivalAttr = modelElement.getDefaultValue();
		
		if(modelAttrName == null) {
            final String msg = LDAPPlugin.Util.getString("LDAPSyncQueryExecution.nullAttrError"); //$NON-NLS-1$
			throw new TranslatorException(msg); 
		}

		Attribute resultAttr = attrs.get(modelAttrName);
		
		// If the attribute is not present, we return NULL.
		if(resultAttr == null) {
			// GHH 20080326 - return DN from input parameter
			// if DN attribute is not present in search result
			if (modelAttrName.equalsIgnoreCase("DN")) {  //$NON-NLS-1$
				return result.getNameInNamespace();
			}
			return null;
		}
		Object objResult = null;
		try {
			if(TypeFacility.RUNTIME_TYPES.STRING.equals(modelAttrClass) && MULTIVALUED_CONCAT.equalsIgnoreCase(multivalAttr)) { 
				// mpw 5/09
				// Order the multi-valued attrs alphabetically before creating a single string, 
				// using the delimiter to separate each token
				ArrayList<String> multivalList = new ArrayList<String>();
				NamingEnumeration<?> attrNE = resultAttr.getAll();
				int length = 0;
				while(attrNE.hasMore()) {
					String val = (String)attrNE.next();
					multivalList.add(val);
					length += ((val==null?0:val.length()) + 1);
				}
				Collections.sort(multivalList);
	
				StringBuilder multivalSB = new StringBuilder(length);
				Iterator<String> itr = multivalList.iterator();
				while(itr.hasNext()) {
					multivalSB.append(itr.next());
					if (itr.hasNext()) {
						multivalSB.append(delimiter);
					}
				}
				return multivalSB.toString();
			}
			if (modelAttrClass.isArray()) {
				return getArray(modelAttrClass.getComponentType(), resultAttr, modelElement, modelAttrName);
			}
			if (unwrap && resultAttr.size() > 1) {
				return getArray(modelAttrClass, resultAttr, modelElement, modelAttrName);
			}
			
			//just a single value
			objResult = resultAttr.get();
		} catch (NamingException ne) {
            final String msg = LDAPPlugin.Util.gs(LDAPPlugin.Event.TEIID12004, modelAttrName) +" : "+ne.getExplanation(); //$NON-NLS-1$m
            LogManager.logWarning(LogConstants.CTX_CONNECTOR, msg);
			throw new TranslatorException(ne, msg);
		}

		return convertSingleValue(modelElement, modelAttrName,
				modelAttrClass, objResult);
	}

	private Object convertSingleValue(Column modelElement,
			String modelAttrName, Class<?> modelAttrClass, Object objResult)
			throws TranslatorException, InvalidNameException {
		if (objResult == null) {
			return null;
		}
		// GHH 20080326 - if attribute is not a string or empty, just
		// return null.
		if (!(objResult instanceof String)) {
			return objResult;
		}

		String strResult = (String)objResult;
		// MPW - 3.9.07 - Also return NULL when attribute is unset or empty string.
		// There is no way to differentiate between being unset and being the empty string.
		if(strResult.equals("")) {  //$NON-NLS-1$
			return null;
		}

		// MPW: 3-11-07: Added support for java.lang.Integer conversion.
		if(TypeFacility.RUNTIME_TYPES.TIMESTAMP.equals(modelAttrClass)) {
			String timestampFormat = modelElement.getFormat();
			if(timestampFormat == null) {
				timestampFormat = LDAPConnectorConstants.ldapTimestampFormat;
			}
			SimpleDateFormat dateFormat = new SimpleDateFormat(timestampFormat);
			try {
				Date dateResult = dateFormat.parse(strResult);
				Timestamp tsResult = new Timestamp(dateResult.getTime());
				return tsResult;
			} catch(ParseException pe) {
				throw new TranslatorException(pe, LDAPPlugin.Util.getString("LDAPSyncQueryExecution.timestampParseFailed", modelAttrName)); //$NON-NLS-1$
			}		
			
			//	TODO: Extend support for more types in the future.
			// Specifically, add support for byte arrays, since that's actually supported
			// in the underlying data source.
		}
		
		//extract rdn
		String type = modelElement.getProperty(LDAPExecutionFactory.RDN_TYPE, false);
		if (type != null) {
			String prefix = modelElement.getProperty(LDAPExecutionFactory.DN_PREFIX, false);
			LdapName name = new LdapName(strResult);
			if (prefix != null) {
				if (!name.getPrefix(name.size() - 1).toString().equals(prefix)) {
					throw new InvalidNameException();
				}
			} else if (name.size() > 1){
				throw new InvalidNameException();
			}
			Rdn rdn = name.getRdn(name.size() - 1);
			if (!rdn.getType().equals(type)) {
				throw new InvalidNameException(); 
			}
			return rdn.getValue();
		}
		
		return strResult; //the Teiid type conversion logic will handle refine from here if necessary
	}

	private ArrayImpl getArray(Class<?> componentType, Attribute resultAttr, Column modelElement, String modelAttrName)
			throws NamingException, TranslatorException {
		ArrayList<Object> multivalList = new ArrayList<Object>();
		NamingEnumeration<?> attrNE = resultAttr.getAll();
		int length = 0;
		while(attrNE.hasMore()) {
			try {
				multivalList.add(convertSingleValue(modelElement, modelAttrName, componentType, attrNE.next()));
				length++;
			} catch (InvalidNameException e) {
				//just ignore
			}
		}
		Object[] values = (Object[]) Array.newInstance(componentType, length);
		ArrayImpl value = new ArrayImpl(multivalList.toArray(values));
		return value;
	}
	
	// for testing.
	LDAPSearchDetails getSearchDetails() {
		return this.searchDetails;
	}
}
