/**
 * 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 org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.kuali.rice.core.api.config.ConfigurationException;
import org.kuali.rice.core.api.impex.xml.XmlConstants;
import org.kuali.rice.core.api.util.ConcreteKeyValue;
import org.kuali.rice.core.api.util.KeyValue;
import org.kuali.rice.core.api.util.xml.XmlHelper;
import org.kuali.rice.core.api.util.xml.XmlJotter;
import org.kuali.rice.kew.api.KewApiConstants;
import org.kuali.rice.kew.api.extension.ExtensionDefinition;
import org.kuali.rice.kew.rule.xmlrouting.XPathHelper;
import org.kuali.rice.kew.util.Utilities;
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.krad.UserSession;
import org.kuali.rice.krad.util.GlobalVariables;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Immutable object that encapsulates the XML searchable attribute content
 */
class XMLSearchableAttributeContent {
    private static final Logger LOG = Logger.getLogger(XMLSearchableAttributeContent.class);

    private ExtensionDefinition def;
    private Element attributeConfig;
    private Node searchingConfig;
    private String searchContent;
    private Map<String, FieldDef> fieldDefs;

    XMLSearchableAttributeContent(ExtensionDefinition ed) {
        this.def = ed;
    }

    XMLSearchableAttributeContent(String configXML) throws SAXException, IOException, ParserConfigurationException {
        this.attributeConfig = XmlHelper.readXml(configXML).getDocumentElement();
    }

    XMLSearchableAttributeContent(Element configXML) {
        if (configXML == null) {
            throw new IllegalArgumentException("Configuration element must not be nil");
        }
        this.attributeConfig = configXML;
    }

    Node getSearchingConfig() throws XPathExpressionException, ParserConfigurationException {
        if (searchingConfig == null) {
            XPath xpath = XPathHelper.newXPath();
            // technically this should probably only be "searchingConfig", and not search the whole tree
            String searchingConfigExpr = "//searchingConfig";
            searchingConfig = (Node) xpath.evaluate(searchingConfigExpr, getAttributeConfig(), XPathConstants.NODE);
        }
        return searchingConfig;
    }

    String getSearchContent() throws XPathExpressionException, ParserConfigurationException {
        if (searchContent == null) {
            Node cfg = getSearchingConfig();
            XPath xpath = XPathHelper.newXPath();
            Node n = (Node) xpath.evaluate("xmlSearchContent", cfg, XPathConstants.NODE);
            if (n != null) {
                StringBuilder sb = new StringBuilder();
                NodeList list = n.getChildNodes();
                for (int i = 0; i < list.getLength(); i++) {
                    sb.append(XmlJotter.jotNode(list.item(i)));
                }
                this.searchContent = sb.toString();
            }
        }
        return searchContent;
    }

    String generateSearchContent(Map<String, String> properties) throws XPathExpressionException, ParserConfigurationException {
        if (properties == null) {
            properties = new HashMap<String, String>();
        }
        // implementation quirk: if no fields were present, empty search content was returned
        List<FieldDef> fields = getFieldDefList();
        if (fields.size() == 0) {
            return "";
        }

        String searchContent = getSearchContent();

        // custom search content template is provided, evaluate it
        if (searchContent != null) {
            String generatedContent = searchContent;
            // if properties have been passed in, perform string replacement
            // NOTE: should default field <value>s also be used for substitution in addition to given properties?
            // Implementation note: if we want to be 100% backwards compatible we can't simply use a global StrSubstitutor
            // to replace all variables.  The implementation actually only replaces variables that are names of
            // *defined fields*; that means properties for fields which are not present on the attribute are NOT replaced.
            for (FieldDef field: fields) {
                if (StringUtils.isNotBlank(field.name)) {
                    String propValue = properties.get(field.name);
                    if (StringUtils.isNotBlank(propValue)) {
                        generatedContent = generatedContent.replaceAll("%" + field.name + "%", propValue);
                    }
                }
            }
            return generatedContent;
        } else { // use a default format
            // Standard doc content if no doc content is found in the searchingConfig xml.
            StringBuilder buf = new StringBuilder("<xmlRouting>");
            for (FieldDef field: fields) {
                if (StringUtils.isNotBlank(field.name)) {
                    String propValue = properties.get(field.name);
                    if (StringUtils.isNotBlank(propValue)) {
                        buf.append("<field name=\"");
                        buf.append(field.name);
                        buf.append("\"><value>");
                        buf.append(propValue);
                        buf.append("</value></field>");
                    }
                }
            }
            buf.append("</xmlRouting>");
            return buf.toString();
        }
    }

    /**
     * Returns a non-null but possibly empty list of FieldDefs
     * @return
     * @throws XPathExpressionException
     * @throws ParserConfigurationException
     */
    List<FieldDef> getFieldDefList() throws XPathExpressionException, ParserConfigurationException {
        return Collections.unmodifiableList(new ArrayList<FieldDef>(getFieldDefs().values()));
    }

    Map<String, FieldDef> getFieldDefs() throws XPathExpressionException, ParserConfigurationException {
        if (fieldDefs == null) {
            fieldDefs = new LinkedHashMap<String, FieldDef>();
            XPath xpath = XPathHelper.newXPath();
            Node searchingConfig = getSearchingConfig();
            if (searchingConfig != null) {
                NodeList list = (NodeList) xpath.evaluate("fieldDef", searchingConfig, XPathConstants.NODESET);
                for (int i = 0; i < list.getLength(); i++) {
                    FieldDef def = new FieldDef(list.item(i));
                    fieldDefs.put(def.name, def);
                }
            }
        }
        return fieldDefs;
    }

    protected Element getAttributeConfig() {
        if (attributeConfig == null) {
            try {
                String xmlConfigData = def.getConfiguration().get(KewApiConstants.ATTRIBUTE_XML_CONFIG_DATA);
                this.attributeConfig = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new BufferedReader(new StringReader(xmlConfigData)))).getDocumentElement();
            } catch (Exception e) {
                String ruleAttrStr = (def == null ? null : def.getName());
                LOG.error("error parsing xml data from search attribute: " + ruleAttrStr, e);
                throw new RuntimeException("error parsing xml data from searchable attribute: " + ruleAttrStr, e);
            }
        }
        return attributeConfig;
    }

    /**
     * Encapsulates a field definition
     */
    static class FieldDef {
        final String name;
        final String title;
        final String defaultValue;
        final Display display;
        final Validation validation;
        final Visibility visibility;
        final SearchDefinition searchDefinition;
        final String fieldEvaluationExpr;
        final Boolean showResultColumn;
        final Lookup lookup;

        FieldDef(Node n) throws XPathExpressionException {
            XPath xpath = XPathHelper.newXPath();
            this.name = getStringAttr(n, "name");
            this.title= getStringAttr(n, "title");
            this.defaultValue = getNodeText(xpath, n, "value");
            this.fieldEvaluationExpr = getNodeText(xpath, n, "fieldEvaluation/xpathexpression");
            this.showResultColumn = getBoolean(xpath, n, "resultColumn/@show");
            // TODO: it might be better to invert responsibility here
            // so we can assign null values for missing entries (at least those we don't expect defaults for)
            this.display = new Display(xpath, n);
            this.validation = new Validation(xpath, n);
            this.visibility = new Visibility(xpath, n);
            this.searchDefinition = new SearchDefinition(xpath, n);
            this.lookup = new Lookup(xpath, n, name);
        }

        /**
         * Returns whether this field should be displayed in search results.  If 'resultColumn/@show' is explicitly defined
         * this value will be used, otherwise it will defer to the field criteria visibility, defaulting to 'true' if unset
         * @return
         */
        boolean isDisplayedInSearchResults() {
            return showResultColumn != null ? showResultColumn : (visibility.visible != null ? visibility.visible : true);
        }

        /**
         * Encapsulates display definition
         */
        static class Display {
            final String type;
            final String meta;
            final String formatter;
            final Collection<KeyValue> options;
            final Collection<String> selectedOptions;

            Display(XPath xpath, Node n) throws XPathExpressionException {
                type = getNodeText(xpath, n, "display/type");
                meta = getNodeText(xpath, n, "display/meta");
                formatter = getNodeText(xpath, n, "display/formatter");
                Collection<KeyValue> options = new ArrayList<KeyValue>();
                Collection<String> selectedOptions = new ArrayList<String>();
                
                NodeList nodes = (NodeList) xpath.evaluate("display[1]/values", n, XPathConstants.NODESET);
                for (int i = 0; i < nodes.getLength(); i++) {
                    Node node = nodes.item(i);
                    boolean selected = getBooleanAttr(node, "selected", false);
                    String title = getStringAttr(node, "title");
                    // TODO: test this - intent is that value without text content results in blank entry?
                    // this is to allow an empty drop down choice and can probably implemented in a better way
                    String value = node.getTextContent();
                    if (value == null) {
                        value = "";
                    }
                    options.add(new ConcreteKeyValue(value, title));
                    if (selected) {
                        selectedOptions.add(node.getTextContent());
                    }
                }

                this.options = Collections.unmodifiableCollection(options);
                this.selectedOptions = Collections.unmodifiableCollection(selectedOptions);
            }
        }

        /**
         * Encapsulates validation definition
         */
        static class Validation {
            final boolean required;
            final String regex;
            final String message;

            Validation(XPath xpath, Node n) throws XPathExpressionException {
                required = Boolean.parseBoolean(getNodeText(xpath, n, "validation/@required"));
                regex = getNodeText(xpath, n, "validation/regex");
                message = getNodeText(xpath, n, "validation/message");
            }
        }

        /**
         * Encapsulates visibility definition
         */
        static class Visibility {
            final Boolean visible;
            final String type;
            final String groupName;
            final String groupNamespace;

            Visibility(XPath xpath, Node n) throws XPathExpressionException {
                Boolean visible = null;
                String type = null;
                String groupName = null;
                String groupNamespace = null;
                Node node = (Node) xpath.evaluate("(visibility/field | visibility/column | visibility/fieldAndColumn)", n, XPathConstants.NODE); // NODE - just use first one
                if (node != null && node instanceof Element) {
                    Element visibilityEl = (Element) node;
                    type = visibilityEl.getNodeName();
                    Attr attr = visibilityEl.getAttributeNode("visible");
                    if (attr != null) {
                        visible = Boolean.valueOf(attr.getValue());
                    }
                    Node groupMember = (Node) xpath.evaluate("(" + XmlConstants.IS_MEMBER_OF_GROUP + "|" + XmlConstants.IS_MEMBER_OF_WORKGROUP + ")", visibilityEl, XPathConstants.NODE);
                    if (groupMember != null && groupMember instanceof Element) {
                        Element groupMemberEl = (Element) groupMember;
                        boolean group_def_found = false;
                        if (XmlConstants.IS_MEMBER_OF_GROUP.equals(groupMember.getNodeName())) {
                            group_def_found = true;
                            groupName = Utilities.substituteConfigParameters(groupMember.getTextContent().trim());
                            groupNamespace = Utilities.substituteConfigParameters(groupMemberEl.getAttribute(XmlConstants.NAMESPACE)).trim();
                        } else if (XmlConstants.IS_MEMBER_OF_WORKGROUP.equals(groupMember.getNodeName())) {
                            group_def_found = true;
                            LOG.warn("Rule Attribute XML is using deprecated element '" + XmlConstants.IS_MEMBER_OF_WORKGROUP +
                                     "', please use '" + XmlConstants.IS_MEMBER_OF_GROUP + "' instead.");
                            String workgroupName = Utilities.substituteConfigParameters(groupMember.getTextContent());
                            groupNamespace = Utilities.parseGroupNamespaceCode(workgroupName);
                            groupName = Utilities.parseGroupName(workgroupName);
                        }
                        if (group_def_found) {
                            if (StringUtils.isEmpty(groupName) || StringUtils.isEmpty(groupNamespace)) {
                                throw new RuntimeException("Both group name and group namespace must be present for group-based visibility.");
                            }

                            GroupService groupService = KimApiServiceLocator.getGroupService();
                            Group group = groupService.getGroupByNamespaceCodeAndName(groupNamespace, groupName);
                            UserSession session = GlobalVariables.getUserSession();
                            if (session != null) {
                             visible =  group == null ? false : groupService.isMemberOfGroup(session.getPerson().getPrincipalId(), group.getId());
                            }
                        }
                    }
                }
                this.visible = visible;
                this.type = type;
                this.groupName = groupName;
                this.groupNamespace = groupNamespace;
            }
        }

        /**
         * Encapsulates a SearchDefinition
         */
        static class SearchDefinition {
            final RangeOptions DEFAULTS = new RangeOptions(null, false, false);
            /**
             * The field search data type.  Guaranteed to be defined (defaulted if missing).
             */
            final String dataType;
            final boolean rangeSearch;
            final RangeOptions searchDef;
            final RangeOptions rangeDef;
            final RangeBound lowerBound;
            final RangeBound upperBound;

            SearchDefinition(XPath xpath, Node n) throws XPathExpressionException {
                String dataType = KewApiConstants.SearchableAttributeConstants.DEFAULT_SEARCHABLE_ATTRIBUTE_TYPE_NAME;
                // if element is missing outright, omit the defaults as well as it cannot be a ranged search
                // caller should check whether this is a ranged search
                RangeOptions searchDefDefaults = new RangeOptions();
                RangeOptions rangeDef = null;
                RangeBound lowerBound = null;
                RangeBound upperBound = null;
                boolean rangeSearch = false;
                Node searchDefNode = (Node) xpath.evaluate("searchDefinition", n, XPathConstants.NODE);
                if (searchDefNode != null) {
                    String s = getStringAttr(searchDefNode, "dataType");
                    // TODO: empty data type should really be invalid or default to something (String?)
                    if (StringUtils.isNotEmpty(s)) {
                        dataType = s;
                    }
                    // clearly there is a conflict if rangeSearch is false while range bounds are defined!
                    // this is not currently enforced
                    rangeSearch = getBooleanAttr(searchDefNode, "rangeSearch", false);

                    searchDefDefaults = new RangeOptions(xpath, searchDefNode, DEFAULTS);
                    Node rangeDefinition = (Node) xpath.evaluate("rangeDefinition", searchDefNode, XPathConstants.NODE);
                    // if range definition element is present, bounds derive settings from range definition
                    if (rangeDefinition != null) {
                        rangeDef = new RangeOptions(xpath, rangeDefinition, searchDefDefaults);
                        Node lower = (Node) xpath.evaluate("lower", rangeDefinition, XPathConstants.NODE);
                        lowerBound = lower == null ? new RangeBound(defaultInclusive(rangeDef, true)) : new RangeBound(xpath, lower, defaultInclusive(rangeDef, true));
                        Node upper = (Node) xpath.evaluate("upper", rangeDefinition, XPathConstants.NODE);
                        upperBound = upper == null ? new RangeBound(defaultInclusive(rangeDef, false)) : new RangeBound(xpath, upper, defaultInclusive(rangeDef, false));
                    } else if (rangeSearch) {
                        // otherwise if range search is specified but no rangedefinition element is present,
                        // bounds use options from search definition element
                        lowerBound = new RangeBound(defaultInclusive(searchDefDefaults, true));
                        upperBound = new RangeBound(defaultInclusive(searchDefDefaults, false));
                    }
                }
                this.dataType = dataType;
                this.rangeSearch = rangeSearch;
                this.searchDef = searchDefDefaults;
                this.rangeDef = rangeDef;
                this.lowerBound = lowerBound;
                this.upperBound = upperBound;
            }

            private static BaseRangeOptions defaultInclusive(BaseRangeOptions opts, boolean inclusive) {
                boolean inc = opts.inclusive == null ? inclusive : opts.inclusive;
                return new BaseRangeOptions(inc, opts.datePicker);
            }

            /**
             * Returns the most specific global/non-bounds options
             */
            public RangeOptions getRangeBoundOptions() {
                return rangeDef == null ? searchDef : rangeDef;
            }

            /**
             * Whether this appears to be a ranged search
             */
            public boolean isRangedSearch() {
                // this is a ranged search if
                // 1) searchDefinition declares this is a rangeSearch
                // OR
                // 2) rangeDefinition/bounds are present in searchDefinition
                return this.rangeSearch || (rangeDef != null);
            }

            /**
             * Base range options class used by search/range definition and bounds elements
             */
            static class BaseRangeOptions {
                protected final Boolean inclusive;
                protected final Boolean datePicker;

                BaseRangeOptions() {
                    this.inclusive = this.datePicker = null;
                }
                BaseRangeOptions(Boolean inclusive, Boolean datePicker) {
                    this.inclusive = inclusive;
                    this.datePicker = datePicker;
                }
                BaseRangeOptions(BaseRangeOptions defaults) {
                    this.inclusive = defaults.inclusive;
                    this.datePicker = defaults.datePicker;
                }
                BaseRangeOptions(XPath xpath, Node n, BaseRangeOptions defaults) {
                    this.inclusive = getBooleanAttr(n, "inclusive", defaults.inclusive);
                    this.datePicker = getBooleanAttr(n, "datePicker", defaults.datePicker);
                }
            }

            /**
             * Reads inclusive, caseSensitive, and datePicker options from attributes of
             * search definition and range definition elements.
             */
            static class RangeOptions extends BaseRangeOptions {
                protected final Boolean caseSensitive;
                RangeOptions() {
                    super();
                    this.caseSensitive = null;
                }
                RangeOptions(Boolean inclusive, Boolean caseSensitive, Boolean datePicker) {
                    super(inclusive, datePicker);
                    this.caseSensitive = caseSensitive;
                }
                RangeOptions(RangeOptions defaults) {
                    super(defaults);
                    this.caseSensitive = defaults.caseSensitive;
                }
                RangeOptions(XPath xpath, Node n, RangeOptions defaults) {
                    super(xpath, n, defaults);
                    this.caseSensitive = getBooleanAttr(n, "caseSensitive", defaults.caseSensitive);
                }
            }

            /**
             * Adds label to BaseRangeOptions
             */
            static class RangeBound extends BaseRangeOptions {
                final String label;
                RangeBound(BaseRangeOptions defaults) {
                    super(defaults);
                    this.label = null;
                }
                RangeBound(XPath xpath, Node n, BaseRangeOptions defaults) {
                    super(xpath, n, defaults);
                    this.label = getStringAttr(n, "label");
                }
            }
        }

        /**
         * Encapsulates a lookup definition
         * <lookup businessObjectClass="org.kuali.rice.kew.docsearch.xml.MyLookupable">
         *   <fieldConversions>
         *     <fieldConversion lookupFieldName="chart" localFieldName="MyBean.data"/>
         *   </fieldConversions>
         * </lookup>
         */
        static class Lookup {
            final String dataObjectClass;
            final Map<String, String> fieldConversions;

            Lookup(XPath xpath, Node n, String fieldName) throws XPathExpressionException {
                String dataObjectClass = null;
                Map<String, String> fieldConversions = new HashMap<String, String>();

                Node lookupNode = (Node) xpath.evaluate("lookup", n, XPathConstants.NODE);
                if (lookupNode != null) {
                    NamedNodeMap quickfinderAttributes = lookupNode.getAttributes();
                    Node dataObjectNode = quickfinderAttributes.getNamedItem("dataObjectClass");
                    if (dataObjectNode == null) {
                        // for legacy compatibility, though businessObjectClass is deprecated
                        dataObjectNode = quickfinderAttributes.getNamedItem("businessObjectClass");
                        if (dataObjectNode != null) {
                            LOG.warn("Field is using deprecated 'businessObjectClass' instead of 'dataObjectClass' for lookup definition, field name is: " + fieldName);
                        } else {
                            throw new ConfigurationException("Failed to locate 'dataObjectClass' for lookup definition.");
                        }
                    }
                    dataObjectClass = dataObjectNode.getNodeValue();
                    NodeList list = (NodeList) xpath.evaluate("fieldConversions/fieldConversion", lookupNode, XPathConstants.NODESET);
                    for (int i = 0; i < list.getLength(); i++) {
                        Node fieldConversionChildNode = list.item(i);
                        NamedNodeMap fieldConversionAttributes = fieldConversionChildNode.getAttributes();
                        // TODO: no validation on these attrs, could throw NPE
                        String lookupFieldName = fieldConversionAttributes.getNamedItem("lookupFieldName").getNodeValue();
                        String localFieldName = fieldConversionAttributes.getNamedItem("localFieldName").getNodeValue();
                        fieldConversions.put(lookupFieldName, localFieldName);
                    }
                }
                
                this.dataObjectClass = dataObjectClass;
                this.fieldConversions = Collections.unmodifiableMap(fieldConversions);
            }
        }
    }

    private static Boolean getBooleanAttr(Node n, String attributeName, Boolean dflt) {
        String nodeValue = getStringAttr(n, attributeName);
        return nodeValue == null ? dflt : Boolean.valueOf(nodeValue);
    }

    private static String getStringAttr(Node n, String attributeName) {
        Node attr = n.getAttributes().getNamedItem(attributeName);
        return attr == null ? null : attr.getNodeValue();
    }

    private static String getNodeText(XPath xpath, Node n, String expression) throws XPathExpressionException {
        Node node = (Node) xpath.evaluate(expression, n, XPathConstants.NODE);
        if (node == null) return null;
        return node.getTextContent();
    }

    private static Boolean getBoolean(XPath xpath, Node n, String expression) throws XPathExpressionException {
        String val = getNodeText(xpath, n, expression);
        return val == null ? null : Boolean.valueOf(val);
    }
}
