/**
 * Copyright 2005-2018 The Kuali Foundation
 *
 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
 *
 * 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.kuali.rice.kew.docsearch.xml;

import java.io.BufferedReader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;

import org.apache.commons.lang.StringUtils;
import org.kuali.rice.core.api.data.DataType;
import org.kuali.rice.core.api.search.Range;
import org.kuali.rice.core.api.search.SearchExpressionUtils;
import org.kuali.rice.core.api.uif.RemotableAbstractControl;
import org.kuali.rice.core.api.uif.RemotableAttributeError;
import org.kuali.rice.core.api.uif.RemotableAttributeField;
import org.kuali.rice.core.api.uif.RemotableAttributeLookupSettings;
import org.kuali.rice.core.api.uif.RemotableDatepicker;
import org.kuali.rice.core.api.uif.RemotableHiddenInput;
import org.kuali.rice.core.api.uif.RemotableQuickFinder;
import org.kuali.rice.core.api.uif.RemotableRadioButtonGroup;
import org.kuali.rice.core.api.uif.RemotableSelect;
import org.kuali.rice.core.api.uif.RemotableTextInput;
import org.kuali.rice.core.api.util.KeyValue;
import org.kuali.rice.core.web.format.Formatter;
import org.kuali.rice.kew.api.KewApiConstants;
import org.kuali.rice.kew.api.WorkflowRuntimeException;
import org.kuali.rice.kew.api.document.DocumentWithContent;
import org.kuali.rice.kew.api.document.attribute.DocumentAttribute;
import org.kuali.rice.kew.api.document.attribute.WorkflowAttributeDefinition;
import org.kuali.rice.kew.api.document.search.DocumentSearchCriteria;
import org.kuali.rice.kew.api.extension.ExtensionDefinition;
import org.kuali.rice.kew.docsearch.CaseAwareSearchableAttributeValue;
import org.kuali.rice.kew.docsearch.DocumentSearchInternalUtils;
import org.kuali.rice.kew.docsearch.SearchableAttributeValue;
import org.kuali.rice.kew.framework.document.attribute.SearchableAttribute;
import org.kuali.rice.kew.rule.xmlrouting.XPathHelper;
import org.kuali.rice.kim.api.group.Group;
import org.kuali.rice.kim.api.group.GroupService;
import org.kuali.rice.kim.api.services.KimApiServiceLocator;
import org.kuali.rice.kns.lookup.LookupUtils;
import org.kuali.rice.krad.UserSession;
import org.kuali.rice.krad.util.GlobalVariables;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

import com.google.common.base.Function;


/**
 * Implementation of a {@code SearchableAttribute} whose configuration is driven from XML.
 *
 * XML configuration must be supplied in the ExtensionDefinition configuration parameter {@link KewApiConstants#ATTRIBUTE_XML_CONFIG_DATA}.
 * Parsing of XML search configuration and generation of XML search content proceeds in an analogous fashion to {@link org.kuali.rice.kew.rule.xmlrouting.StandardGenericXMLRuleAttribute}.
 * Namely, if an <pre>searchingConfig/xmlSearchContent</pre> element is provided, its content is used as a template.  Otherwise a standard XML template is used.
 * This template is parameterized with variables of the notation <pre>%name%</pre> which are resolved by <pre>searchingConfig/fieldDef[@name]</pre> definitions.
 *
 * The XML content is not validated, but it must be well formed.
 *
 * Example 1:
 * <pre>
 *     <searchingConfig>
 *         <fieldDef name="def1" ...other attrs/>
 *             ... other config
 *         </fieldDef>
 *         <fieldDef name="def2" ...other attrs/>
 *             ... other config
 *         </fieldDef>
 *     </searchingConfig>
 * </pre>
 * Produces, when supplied with the workflow definition parameters: { def1: val1, def2: val2 }:
 * <pre>
 *     <xmlRouting>
 *         <field name="def1"><value>val1</value></field>
 *         <field name="def2"><value>val2</value></field>
 *     </xmlRouting>
 * </pre>
 *
 * Example 2:
 * <pre>
 *     <searchingConfig>
 *         <xmlSearchContent>
 *             <myGeneratedContent>
 *                 <version>whatever</version>
 *                 <anythingIWant>Once upon a %def1%...</anythingIWant>
 *                 <conclusion>Happily ever %def2%.</conclusion>
 *             </myGeneratedContent>
 *         </xmlSearchContent>
 *         <fieldDef name="def1" ...other attrs/>
 *             ... other config
 *         </fieldDef>
 *         <fieldDef name="def2" ...other attrs/>
 *             ... other config
 *         </fieldDef>
 *     </searchingConfig>
 * </pre>
 * Produces, when supplied with the workflow definition parameters: { def1: val1, def2: val2 }:
 * <pre>
 *     <myGeneratedContent>
 *         <version>whatever</version>
 *         <anythingIWant>Once upon a val1...</anythingIWant>
 *         <conclusion>Happily ever val2.</conclusion>
 *     </myGeneratedContent>
 * </pre>
 * @author Kuali Rice Team (rice.collab@kuali.org)
 */
public class StandardGenericXMLSearchableAttribute implements SearchableAttribute {

	private static final org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(StandardGenericXMLSearchableAttribute.class);
    private static final String FIELD_DEF_E = "fieldDef";
    /**
     * Compile-time option that controls whether we check and return errors for field bounds options that conflict with searchable attribute configuration.
     */
    private static final boolean PEDANTIC_BOUNDS_VALIDATION = true;


    @Override
    public String generateSearchContent(ExtensionDefinition extensionDefinition, String documentTypeName, WorkflowAttributeDefinition attributeDefinition) {
        Map<String, String> propertyDefinitionMap = attributeDefinition.getPropertyDefinitionsAsMap();
        try {
            XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
            return content.generateSearchContent(propertyDefinitionMap);
        } catch (XPathExpressionException e) {
            LOG.error("error in getSearchContent ", e);
            throw new RuntimeException("Error trying to find xml content with xpath expression", e);
        } catch (Exception e) {
            LOG.error("error in getSearchContent attempting to find xml search content", e);
            throw new RuntimeException("Error trying to get xml search content.", e);
        }
    }

    @Override
    public List<DocumentAttribute> extractDocumentAttributes(ExtensionDefinition extensionDefinition, DocumentWithContent documentWithContent) {
        List<DocumentAttribute> searchStorageValues = new ArrayList<DocumentAttribute>();
        String fullDocumentContent = documentWithContent.getDocumentContent().getFullContent();
        if (StringUtils.isBlank(documentWithContent.getDocumentContent().getFullContent())) {
            LOG.warn("Empty Document Content found for document id: " + documentWithContent.getDocument().getDocumentId());
            return searchStorageValues;
        }
        Document document;
        try {
            document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new BufferedReader(new StringReader(fullDocumentContent))));
        } catch (Exception e){
            LOG.error("error parsing docContent: "+documentWithContent.getDocumentContent(), e);
            throw new RuntimeException("Error trying to parse docContent: "+documentWithContent.getDocumentContent(), e);
        }
        XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
        List<XMLSearchableAttributeContent.FieldDef> fields;
        try {
            fields = content.getFieldDefList();
        } catch (XPathExpressionException xpee) {
            throw new RuntimeException("Error parsing searchable attribute content", xpee);
        } catch (ParserConfigurationException pce) {
            throw new RuntimeException("Error parsing searchable attribute content", pce);
        }
        XPath xpath = XPathHelper.newXPath(document);
        for (XMLSearchableAttributeContent.FieldDef field: fields) {
            if (StringUtils.isNotEmpty(field.fieldEvaluationExpr)) {
                List<String> values = new ArrayList<String>();
                try {
                    LOG.debug("Trying to retrieve node set with expression: '" + field.fieldEvaluationExpr + "'.");
                    NodeList searchValues = (NodeList) xpath.evaluate(field.fieldEvaluationExpr, document.getDocumentElement(), XPathConstants.NODESET);
                    // being that this is the standard xml attribute we will return the key with an empty value
                    // so we can find it from a doc search using this key
                    for (int j = 0; j < searchValues.getLength(); j++) {
                        Node searchValue = searchValues.item(j);
                        if (searchValue.getFirstChild() != null && (StringUtils.isNotEmpty(searchValue.getFirstChild().getNodeValue()))) {
                            values.add(searchValue.getFirstChild().getNodeValue());
                        }
                    }
                } catch (XPathExpressionException e) {
                    LOG.debug("Could not retrieve node set with expression: '" + field.fieldEvaluationExpr + "'. Trying string return type.");
                    //try for a string being returned from the expression.  This
                    //seems like a poor way to determine our expression return type but
                    //it's all I can come up with at the moment.
                    try {
                        String searchValue = (String) xpath.evaluate(field.fieldEvaluationExpr, document.getDocumentElement(), XPathConstants.STRING);
                        if (StringUtils.isNotBlank(searchValue)) {
                            values.add(searchValue);
                        }
                    } catch (XPathExpressionException xpee) {
                        LOG.error("Error retrieving string with expression: '" + field.fieldEvaluationExpr + "'", xpee);
                        throw new RuntimeException("Error retrieving string with expression: '" + field.fieldEvaluationExpr + "'", xpee);
                    }
                }

                // remove any nulls
                values.removeAll(Collections.singleton(null));
                // being that this is the standard xml attribute we will return the key with an empty value
                // so we can find it from a doc search using this key
                if (values.isEmpty()) {
                    values.add(null);
                }
                for (String value: values) {
                    DocumentAttribute searchableValue = this.setupSearchableAttributeValue(field.searchDefinition.dataType, field.name, value);
                    if (searchableValue != null) {
                        searchStorageValues.add(searchableValue);
                    }
                }
            }
        }
        return searchStorageValues;
    }

    private DocumentAttribute setupSearchableAttributeValue(String dataType, String key, String value) {
        SearchableAttributeValue attValue = DocumentSearchInternalUtils.getSearchableAttributeValueByDataTypeString(dataType);
        if (attValue == null) {
            String errorMsg = "Cannot find a SearchableAttributeValue associated with the data type '" + dataType + "'";
            LOG.error("setupSearchableAttributeValue() " + errorMsg);
            throw new RuntimeException(errorMsg);
        }
        value = (value != null) ? value.trim() : null;
        if ( (StringUtils.isNotBlank(value)) && (!attValue.isPassesDefaultValidation(value)) ) {
            String errorMsg = "SearchableAttributeValue with the data type '" + dataType + "', key '" + key + "', and value '" + value + "' does not pass default validation and cannot be saved to the database";
            LOG.error("setupSearchableAttributeValue() " + errorMsg);
            throw new RuntimeException(errorMsg);
        }
        attValue.setSearchableAttributeKey(key);
        attValue.setupAttributeValue(value);
        return attValue.toDocumentAttribute();
    }

    @Override
    public List<RemotableAttributeField> getSearchFields(ExtensionDefinition extensionDefinition, String documentTypeName) {
        List<RemotableAttributeField> searchFields = new ArrayList<RemotableAttributeField>();
        List<SearchableAttributeValue> searchableAttributeValues = DocumentSearchInternalUtils.getSearchableAttributeValueObjectTypes();

        XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
        List<XMLSearchableAttributeContent.FieldDef> fields;
        try {
            fields = content.getFieldDefList();
        } catch (XPathExpressionException xpee) {
            throw new RuntimeException("Error parsing searchable attribute configuration", xpee);
        } catch (ParserConfigurationException pce) {
            throw new RuntimeException("Error parsing searchable attribute configuration", pce);
        }
        for (XMLSearchableAttributeContent.FieldDef field: fields) {
            searchFields.add(convertFieldDef(field, searchableAttributeValues));
        }

        return searchFields;
    }

    /**
     * Converts a searchable attribute FieldDef to a RemotableAttributeField
     */
    private RemotableAttributeField convertFieldDef(XMLSearchableAttributeContent.FieldDef field, Collection<SearchableAttributeValue> searchableAttributeValues) {
        RemotableAttributeField.Builder fieldBuilder = RemotableAttributeField.Builder.create(field.name);

        fieldBuilder.setLongLabel(field.title);

        RemotableAttributeLookupSettings.Builder attributeLookupSettings = RemotableAttributeLookupSettings.Builder.create();
        fieldBuilder.setAttributeLookupSettings(attributeLookupSettings);

        // value
        if (field.defaultValue != null) {
            fieldBuilder.setDefaultValues(Collections.singletonList(field.defaultValue));
        }

        // Visibility
        applyVisibility(fieldBuilder, attributeLookupSettings, field);

        // Display
        RemotableAbstractControl.Builder controlBuilder = constructControl(field.display.type, field.display.options);
        fieldBuilder.setControl(controlBuilder);
        if ("date".equals(field.display.type)) {
            fieldBuilder.getWidgets().add(RemotableDatepicker.Builder.create());
            fieldBuilder.setDataType(DataType.DATE);
        }
        if (!field.display.selectedOptions.isEmpty()) {
            fieldBuilder.setDefaultValues(field.display.selectedOptions);
        }

        // resultcolumn
        attributeLookupSettings.setInResults(field.isDisplayedInSearchResults());

        // SearchDefinition
        // data type operations
        DataType dataType = DocumentSearchInternalUtils.convertValueToDataType(field.searchDefinition.dataType);
        fieldBuilder.setDataType(dataType);
        if (DataType.DATE == fieldBuilder.getDataType()) {
            fieldBuilder.getWidgets().add(RemotableDatepicker.Builder.create());
        }

        boolean isRangeSearchField = isRangeSearchField(searchableAttributeValues, fieldBuilder.getDataType(), field);
        if (isRangeSearchField) {
            attributeLookupSettings.setRanged(true);
            // we've established the search is ranged, so we can inspect the bounds
            attributeLookupSettings.setLowerBoundInclusive(field.searchDefinition.lowerBound.inclusive);
            attributeLookupSettings.setUpperBoundInclusive(field.searchDefinition.upperBound.inclusive);
            attributeLookupSettings.setLowerLabel(field.searchDefinition.lowerBound.label);
            attributeLookupSettings.setUpperLabel(field.searchDefinition.upperBound.label);
            attributeLookupSettings.setLowerDatePicker(field.searchDefinition.lowerBound.datePicker);
            attributeLookupSettings.setUpperDatePicker(field.searchDefinition.upperBound.datePicker);
        }

        Boolean caseSensitive = field.searchDefinition.getRangeBoundOptions().caseSensitive;
        if (caseSensitive != null) {
            attributeLookupSettings.setCaseSensitive(caseSensitive);
        }

        /**



         String formatterClass = (searchDefAttributes.getNamedItem("formatterClass") == null) ? null : searchDefAttributes.getNamedItem("formatterClass").getNodeValue();
         if (!StringUtils.isEmpty(formatterClass)) {
         try {
         myField.setFormatter((Formatter)Class.forName(formatterClass).newInstance());
         } catch (InstantiationException e) {
         LOG.error("Unable to get new instance of formatter class: " + formatterClass);
         throw new RuntimeException("Unable to get new instance of formatter class: " + formatterClass);
         }
         catch (IllegalAccessException e) {
         LOG.error("Unable to get new instance of formatter class: " + formatterClass);
         throw new RuntimeException("Unable to get new instance of formatter class: " + formatterClass);
         } catch (ClassNotFoundException e) {
         LOG.error("Unable to find formatter class: " + formatterClass);
         throw new RuntimeException("Unable to find formatter class: " + formatterClass);
         }
         }

         */

         String formatter = field.display.formatter == null ? null : field.display.formatter;
         fieldBuilder.setFormatterName(formatter);

        try {
        // Register this formatter so that you can use it later in FieldUtils when processing
            if(StringUtils.isNotEmpty(formatter)){
                Formatter.registerFormatter(Class.forName(formatter), Class.forName(formatter));
            }
        } catch (ClassNotFoundException e) {
         LOG.error("Unable to find formatter class: " + formatter);
         throw new RuntimeException("Unable to find formatter class: " + formatter);
         }


        // Lookup
        // XMLAttributeUtils.establishFieldLookup(fieldBuilder, childNode); // this code can probably die now that parsing has moved out to xmlsearchableattribcontent
        if (field.lookup.dataObjectClass != null) {
            RemotableQuickFinder.Builder quickFinderBuilder = RemotableQuickFinder.Builder.create(LookupUtils.getBaseLookupUrl(false), field.lookup.dataObjectClass);
            quickFinderBuilder.setFieldConversions(field.lookup.fieldConversions);
            fieldBuilder.getWidgets().add(quickFinderBuilder);
        }

        return fieldBuilder.build();
    }


    /**
     * Determines whether the searchable field definition is a ranged search
     * @param searchableAttributeValues the possible system {@link SearchableAttributeValue}s
     * @param dataType the UI data type
     * @return
     */
    private boolean isRangeSearchField(Collection<SearchableAttributeValue> searchableAttributeValues, DataType dataType, XMLSearchableAttributeContent.FieldDef field) {
        for (SearchableAttributeValue attValue : searchableAttributeValues)
        {
            DataType attributeValueDataType = DocumentSearchInternalUtils.convertValueToDataType(attValue.getAttributeDataType());
            if (attributeValueDataType == dataType) {
                return isRangeSearchField(attValue, field);
            }
        }
        String errorMsg = "Could not find searchable attribute value for data type '" + dataType + "'";
        LOG.error("isRangeSearchField(List, String, NamedNodeMap, Node) " + errorMsg);
        throw new WorkflowRuntimeException(errorMsg);
    }

    private boolean isRangeSearchField(SearchableAttributeValue searchableAttributeValue, XMLSearchableAttributeContent.FieldDef field) {
        // this is a ranged search if
        // 1) attribute value type allows ranged search
        boolean allowRangedSearch = searchableAttributeValue.allowsRangeSearches();
        // AND
        // 2) the searchDefinition specifies a ranged search
        return allowRangedSearch && field.searchDefinition.isRangedSearch();
    }

    /**
     * Applies visibility settings to the RemotableAttributeField
     */
    private void applyVisibility(RemotableAttributeField.Builder fieldBuilder, RemotableAttributeLookupSettings.Builder attributeLookupSettings, XMLSearchableAttributeContent.FieldDef field) {
        boolean visible = true;
        // if visibility is explicitly set, use it
        if (field.visibility.visible != null) {
            visible = field.visibility.visible;
        } else {
            if (field.visibility.groupName != null) {
                UserSession session = GlobalVariables.getUserSession();
                if (session == null) {
                    throw new WorkflowRuntimeException("UserSession is null!  Attempted to render the searchable attribute outside of an established session.");
                }
                GroupService groupService = KimApiServiceLocator.getGroupService();

                Group group = groupService.getGroupByNamespaceCodeAndName(field.visibility.groupNamespace, field.visibility.groupName);
                visible =  group == null ? false : groupService.isMemberOfGroup(session.getPerson().getPrincipalId(), group.getId());
            }
        }
        String type = field.visibility.type;
        if ("field".equals(type) || "fieldAndColumn".equals(type)) {
            // if it's not visible, coerce this field to a hidden type
            if (!visible) {
                fieldBuilder.setControl(RemotableHiddenInput.Builder.create());
            }
        }
        if ("column".equals(type) || "fieldAndColumn".equals(type)) {
            attributeLookupSettings.setInResults(visible);
        }
    }

    private RemotableAbstractControl.Builder constructControl(String type, Collection<KeyValue> options) {
        RemotableAbstractControl.Builder control = null;
        Map<String, String> optionMap = new LinkedHashMap<String, String>();
        for (KeyValue option : options) {
            optionMap.put(option.getKey(), option.getValue());
        }
        if ("text".equals(type) || "date".equals(type)) {
            control = RemotableTextInput.Builder.create();
        } else if ("select".equals(type)) {
            control = RemotableSelect.Builder.create(optionMap);
        } else if ("radio".equals(type)) {
            control = RemotableRadioButtonGroup.Builder.create(optionMap);
        } else if ("hidden".equals(type)) {
            control = RemotableHiddenInput.Builder.create();
        } else if ("multibox".equals(type)) {
            RemotableSelect.Builder builder = RemotableSelect.Builder.create(optionMap);
            builder.setMultiple(true);
            control = builder;
        } else {
            throw new IllegalArgumentException("Illegal field type found: " + type);
        }
        return control;
    }

    @Override
    public List<RemotableAttributeError> validateDocumentAttributeCriteria(ExtensionDefinition extensionDefinition, DocumentSearchCriteria documentSearchCriteria) {
		List<RemotableAttributeError> errors = new ArrayList<RemotableAttributeError>();
        
        Map<String, List<String>> documentAttributeValues = documentSearchCriteria.getDocumentAttributeValues();
        if (documentAttributeValues == null || documentAttributeValues.isEmpty()) {
            // nothing to validate...
            return errors;
        }

        XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
        List<XMLSearchableAttributeContent.FieldDef> fields;
        try {
            fields = content.getFieldDefList();
        } catch (XPathExpressionException xpee) {
            throw new RuntimeException("Error parsing searchable attribute configuration", xpee);
        } catch (ParserConfigurationException pce) {
            throw new RuntimeException("Error parsing searchable attribute configuration", pce);
        }
        if (fields.isEmpty()) {
            LOG.warn("Could not find any field definitions (<" + FIELD_DEF_E + ">) or possibly a searching configuration (<searchingConfig>) for this XMLSearchAttribute");
            return errors;
        }

        for (XMLSearchableAttributeContent.FieldDef field: fields) {
            String fieldDefName = field.name;
            String fieldDefTitle = field.title == null ? "" : field.title;

            List<String> testObject = documentAttributeValues.get(fieldDefName);

            if (testObject == null || testObject.isEmpty()) {
                // no value to validate
                // not checking for 'required' here since this is *search* criteria, and required field can be omitted
                continue;
            }

            // What type of value is this searchable attribute field?
            // get the searchable attribute value by using the data type
            SearchableAttributeValue attributeValue = DocumentSearchInternalUtils.getSearchableAttributeValueByDataTypeString(field.searchDefinition.dataType);
            if (attributeValue == null) {
                String errorMsg = "Cannot find SearchableAttributeValue for field data type '" + field.searchDefinition.dataType + "'";
                LOG.error("validateUserSearchInputs() " + errorMsg);
                throw new RuntimeException(errorMsg);
            }

            // 1) parse concrete values from possible range expressions
            // 2) validate any resulting concrete values whether they were original arguments or parsed from range expressions
            // 3) if the expression was a range expression, validate the logical validity of the range bounds

            List<String> terminalValues = new ArrayList<String>();
            List<Range> rangeValues = new ArrayList<Range>();

            // we are assuming here that the only expressions evaluated against searchable attributes are simple
            // non-compound expressions.  parsing compound expressions would require full grammar/parsing support
            // and would probably be pretty absurd assuming these queries are coming from UIs.
            // If they are not coming from the UI, do we need to support compound expressions?
            for (String value: testObject) {
                // is this a terminal value or does it look like a range?
                if (value == null) {
                    // assuming null values are not an error condition
                    continue;
                }
                // this is just a war of attrition, need real parsing
                String[] clauses = SearchExpressionUtils.splitOnClauses(value);
                for (String clause: clauses) {
                    // if it's not empty. see if it's a range
                    Range r = null;
                    if (StringUtils.isNotEmpty(value)) {
                        r = SearchExpressionUtils.parseRange(value);
                    }
                    if (r != null) {
                        // hey, it looks like a range
                        boolean errs = false;
                        if (!field.searchDefinition.isRangedSearch()) {
                            errs = true;
                            errors.add(RemotableAttributeError.Builder.create(field.name, "field does not support ranged searches but range search expression detected").build());
                        } else {
                            // only check bounds if range search is specified
                            // XXX: FIXME: disabling these pedantic checks as they are causing annoying test breakages
                            if (PEDANTIC_BOUNDS_VALIDATION) {
                                // this is not actually an error. just disregard case-sensitivity for data types that don't support it
                                /*if (!attributeValue.allowsCaseInsensitivity() && Boolean.FALSE.equals(field.searchDefinition.getRangeBoundOptions().caseSensitive)) {
                                    errs = true;
                                    errors.add(RemotableAttributeError.Builder.create(field.name, "attribute data type does not support case insensitivity but case-insensitivity specified in attribute definition").build());
                                }*/
                                if (r.getLowerBoundValue() != null && r.isLowerBoundInclusive() != field.searchDefinition.lowerBound.inclusive) {
                                    errs = true;
                                    errors.add(RemotableAttributeError.Builder.create(field.name, "range expression ('" + value + "') and attribute definition differ on lower bound inclusivity.  Range is: " + r.isLowerBoundInclusive() + " Attrib is: " + field.searchDefinition.lowerBound.inclusive).build());
                                }
                                if (r.getUpperBoundValue() != null && r.isUpperBoundInclusive() != field.searchDefinition.upperBound.inclusive) {
                                    errs = true;
                                    errors.add(RemotableAttributeError.Builder.create(field.name, "range expression ('" + value + "') and attribute definition differ on upper bound inclusivity.  Range is: " + r.isUpperBoundInclusive() + " Attrib is: " + field.searchDefinition.upperBound.inclusive).build());
                                }
                            }
                        }

                        if (!errs) {
                            rangeValues.add(r);
                        }
                    } else {
                        terminalValues.add(value);
                    }
                }
            }

            List<String> parsedValues = new ArrayList<String>();
            // validate all values
            for (String value: terminalValues) {
                errors.addAll(performValidation(attributeValue, field, value, fieldDefTitle, parsedValues));
            }
            for (Range range: rangeValues) {
                List<String> parsedLowerValues = new ArrayList<String>();
                List<String> parsedUpperValues = new ArrayList<String>();
                List<RemotableAttributeError> lowerErrors = performValidation(attributeValue, field,
                        range.getLowerBoundValue(), constructRangeFieldErrorPrefix(field.title,
                        field.searchDefinition.lowerBound), parsedLowerValues);
                errors.addAll(lowerErrors);
                List<RemotableAttributeError> upperErrors = performValidation(attributeValue, field, range.getUpperBoundValue(),
                        constructRangeFieldErrorPrefix(field.title, field.searchDefinition.upperBound), parsedUpperValues);
                errors.addAll(upperErrors);

                // if both values check out, perform logical range validation
                if (lowerErrors.isEmpty() && upperErrors.isEmpty()) {
                    // TODO: how to handle multiple values?? doesn't really make sense
                    String lowerBoundValue = parsedLowerValues.isEmpty() ? null : parsedLowerValues.get(0);
                    String upperBoundValue = parsedUpperValues.isEmpty() ? null : parsedUpperValues.get(0);

                    final Boolean rangeValid;
                    // for the sake of string searches, make sure the bounds are uppercased before comparison if the search
                    // is case sensitive.
                    if (KewApiConstants.SearchableAttributeConstants.DATA_TYPE_STRING.equals(field.searchDefinition.dataType)) {
                        boolean caseSensitive = field.searchDefinition.getRangeBoundOptions().caseSensitive == null ? true : field.searchDefinition.getRangeBoundOptions().caseSensitive;
                        rangeValid = ((CaseAwareSearchableAttributeValue) attributeValue).isRangeValid(lowerBoundValue, upperBoundValue, caseSensitive);
                    } else {
                        rangeValid = attributeValue.isRangeValid(lowerBoundValue, upperBoundValue);
                    }

                    if (rangeValid != null && !rangeValid) {
                        String errorMsg = "The " + fieldDefTitle + " range is incorrect.  The " +
                                (StringUtils.isNotBlank(field.searchDefinition.lowerBound.label) ? field.searchDefinition.lowerBound.label : KewApiConstants.SearchableAttributeConstants.DEFAULT_RANGE_SEARCH_LOWER_BOUND_LABEL)
                                + " value entered must come before the " +
                                (StringUtils.isNotBlank(field.searchDefinition.upperBound.label) ? field.searchDefinition.upperBound.label : KewApiConstants.SearchableAttributeConstants.DEFAULT_RANGE_SEARCH_UPPER_BOUND_LABEL)
                                + " value";
                        LOG.debug("validateUserSearchInputs() " + errorMsg + " :: field type '" + attributeValue.getAttributeDataType() + "'");
                        errors.add(RemotableAttributeError.Builder.create(fieldDefName, errorMsg).build());
                    }
                }
            }
       }
        return errors;
    }

    private String constructRangeFieldErrorPrefix(String fieldDefLabel, XMLSearchableAttributeContent.FieldDef.SearchDefinition.RangeBound rangeBound) {
        if ( StringUtils.isNotBlank(rangeBound.label) && StringUtils.isNotBlank(fieldDefLabel)) {
            return fieldDefLabel + " " + rangeBound.label + " Field";
        } else if (StringUtils.isNotBlank(fieldDefLabel)) {
            return fieldDefLabel + " Range Field";
        } else if (StringUtils.isNotBlank(rangeBound.label)) {
            return "Range Field " + rangeBound.label + " Field";
        }
        return null;
    }

    /**
     * Performs validation on a single DSC attribute value, running any defined custom validation regex after basic validation
     * @param attributeValue the searchable attribute value type
     * @param field the XMLSearchableAttributeContent field
     * @param enteredValue the value to validate
     * @param errorMessagePrefix a prefix for error messages
     * @param resultingValues optional list of accumulated parsed values
     * @return a (possibly empty) list of errors
     */
    private List<RemotableAttributeError> performValidation(SearchableAttributeValue attributeValue, final XMLSearchableAttributeContent.FieldDef field, String enteredValue, String errorMessagePrefix, List<String> resultingValues) {
        return DocumentSearchInternalUtils.validateSearchFieldValue(field.name, attributeValue, enteredValue, errorMessagePrefix, resultingValues, new Function<String, Collection<RemotableAttributeError>>() {
            @Override
            public Collection<RemotableAttributeError> apply(String value) {
                if (StringUtils.isNotEmpty(field.validation.regex)) {
                    Pattern pattern = Pattern.compile(field.validation.regex);
                    Matcher matcher = pattern.matcher(value);
                    if (!matcher.matches()) {
                        return Collections.singletonList(RemotableAttributeError.Builder.create(field.name, field.validation.message).build());
                    }
                }
                return Collections.emptyList();
            }
        });
    }

    // preserved only for subclasses
    protected Element getConfigXML(ExtensionDefinition extensionDefinition) {
        try {
            String xmlConfigData = extensionDefinition.getConfiguration().get(KewApiConstants.ATTRIBUTE_XML_CONFIG_DATA);
            return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new BufferedReader(new StringReader(xmlConfigData)))).getDocumentElement();
        } catch (Exception e) {
            String ruleAttrStr = (extensionDefinition == null ? null : extensionDefinition.getName());
            LOG.error("error parsing xml data from search attribute: " + ruleAttrStr, e);
            throw new RuntimeException("error parsing xml data from searchable attribute: " + ruleAttrStr, e);
        }
    }
}
