# Source: src/js/models/filters/FilterGroup.js

    /* global define */
    define(["jquery", "underscore", "backbone", "collections/Filters", "models/filters/Filter" ],
      function ($, _, Backbone, Filters, Filter) {

        /**
        * @class FilterGroup
        * @classdesc A group of multiple Filters, and optionally nested Filter Groups, which
        * may be combined to create a complex query. A FilterGroup may be a Collection
        * FilterGroupType or a Portal UIFilterGroupType.
        * @classcategory Models/Filters
        * @extends Backbone.Model
        * @constructs
        */
        var FilterGroup = Backbone.Model.extend(
        /** @lends FilterGroup.prototype */{

            /**
              * The name of this Model
              * @type {string}
              * @readonly
              */
            type: "FilterGroup",

            /**
             * Default attributes for FilterGroup models
             * @type {Object}
             * @property {string} label - For UIFilterGroupType filter groups, a
             * human-readable short label for this Filter Group
             * @property {string} description - For UIFilterGroupType filter groups, a
             * description of the Filter Group's function
             * @property {string} icon - For UIFilterGroupType filter groups, a term that
             * identifies a single icon in a supported icon library.
             * @property {Filters} filters - A collection of Filter models that represent a
             * full or partial query
             * @property {XMLElement} objectDOM - FilterGroup XML
             * @property {string} operator - The operator to use between filters (including
             * filter groups) set on this model. Must be set to "AND" or "OR".
             * @property {boolean} exclude - If true, search index docs matching the filters
             * within this group will be excluded from the search results
             * @property {boolean} isUIFilterType - Set to true if this group is
             * UIFilterGroupType (aka custom Portal search filter). Otherwise, it's assumed
             * that this model is FilterGroupType (e.g. a Collection FilterGroupType)
             * @property {string} nodeName - the XML node name to use when serializing this
             * model. For example, may be "filterGroup" or "definition".
             * @property {boolean} isInvisible - If true, this filter will be added to the
             * query but will act in the "background", like a default filter. It will not
             * appear in the Query Builder or other UIs. If this is invisible, then the
             * "isInvisible" property on sub-filters will be ignored.
             * @property {boolean} mustMatchIds - If the search results must always match one
             * of the ids in the id filters, then the id filters will be added to the query
             * with an AND operator.
            */
            defaults: function () {
              return {
                label: null,
                description: null,
                icon: null,
                filters: null,
                objectDOM: null,
                operator: "AND",
                exclude: false,
                isUIFilterType: false,
                nodeName: "filterGroup",
                isInvisible: false,
                mustMatchIds: false
                // TODO: support options for UIFilterGroupType 1.1.0
                // options: [],
              }
            },

            /**
            * This function is executed whenever a new FilterGroup model is created. Model
            * attributes are set either by parsing attributes.objectDOM or ny extracting the
            * properties from attributes (e.g. attributes.nodeName, attributes.operator, etc)
            */
            initialize: function (attributes) {

              if(!attributes){
                attributes = {}
              }

              if(attributes.isUIFilterType){
                this.set("isUIFilterType", true);
              }

              // When a Filter model within this Filter group changes, or when the Filters
              // collection is updated, trigger a change event in this filterGroup model.
              // Updates and Changes in the Filters collection won't trigger an event from
              // this model otherwise. This helps when other models, collections, views are
              // listening to this filterGroup, e.g. when the collections model updates the
              // searchModel whenever the definition changes.
              this.off("change:filters");
              this.on("change:filters", function(){
                this.stopListening(this.get("filters"), "update change");
                this.listenTo(
                  this.get("filters"),
                  "update change",
                  function(model, record){
                    this.trigger("update", model, record)
                  }
                );
              }, this);

              var newFiltersOptions = {};
              var catalogSearch = false;

              if(attributes.catalogSearch){
                this.set("catalogSearch", true)
              }

              // Set the attributes on this model by parsing XML if some was provided,
              // or by using any attributes provided to this model
              if (attributes.objectDOM) {
                var groupAttrs = this.parse(attributes.objectDOM, catalogSearch);
                this.set(groupAttrs);
              } else{
                ["label", "description", "icon", "operator",
                 "exclude", "nodeName", "isInvisible"].forEach(function(modelAttribute){
                  if(attributes[modelAttribute] || attributes[modelAttribute] === false){
                    this.set(modelAttribute, attributes[modelAttribute])
                  }
                }, this);
              }

              if (attributes.filters) {
                var filtersCollection = new Filters(null, newFiltersOptions);
                filtersCollection.add(attributes.filters);
                this.set("filters", filtersCollection);
              }

              // Start a new Filters collection if no filters were provided
              if(!this.get("filters")){
                this.set("filters", new Filters(null, newFiltersOptions))
              }

              if (attributes.mustMatchIds) {
                this.set("mustMatchIds", true);
                this.get("filters").mustMatchIds = true;
              }

              // The operator must be AND or OR
              if( !["AND", "OR"].includes(this.get("operator")) ){
                // Set the value to the default
                this.set("operator", this.defaults()["operator"])
              }

            },

            /**
            * Overrides the default Backbone.Model.parse() function to parse the filterGroup
            * XML snippet
            *
            * @param {Element} xml - The XML Element that contains all the FilterGroup elements
            * @param {boolean} catalogSearch [false] - Set to true to append a catalog search phrase
            * to the search query created from Filters that limits the results to un-obsoleted
            * metadata.
            * @return {JSON} The result of the parsed XML, in JSON. To be set directly on the model.
            */
            parse: function (xml, catalogSearch = false) {

              var modelJSON = {}

              if(!xml){
                return modelJSON
              }

              // FilterGroups can be either <definition> or <filterGroup>
              this.set("nodeName", xml.nodeName);

              // Parse all the text nodes. Node names and model attributes always match
              // in this case.
              ["label", "description", "icon", "operator"].forEach(
                function(nodeName){
                  if($(xml).find(nodeName).length){
                    modelJSON[nodeName] = this.parseTextNode(xml, nodeName)
                  }
                },
                this
              );

              // Parse the exclude field node (true or false)
              if( $(xml).find("exclude").length ){
                modelJSON.exclude = (this.parseTextNode(xml, "exclude") === "true") ? true : false;
              }

              // Remove any nodes that aren't filters or filter groups from the XML
              var filterNodeNames = [
                "filter", "booleanFilter", "dateFilter", "numericFilter", "filterGroup",
                "choiceFilter", "toggleFilter"
              ]
              filterXML = xml.cloneNode(true)
              $(filterXML)
                .children()
                .not(filterNodeNames.join(", "))
                .remove();

              // Add the filters and nested filter groups to this filters model
              // TODO: Add isNested property for filterGroups that are within filterGroups?
              var filtersOptions = {
                objectDOM: filterXML,
                isUIFilterType: this.get("isUIFilterType"),
              }

              if(catalogSearch){
                filtersOptions.catalogSearch = true
              }

              modelJSON.filters = new Filters(null, filtersOptions);

              return modelJSON;
            },

            /**
            * Gets the text content of the XML node matching the given node name
            *
            * @param {Element} parentNode - The parent node to select from
            * @param {string} nodeName - The name of the XML node to parse
            * @param {boolean} isMultiple - If true, parses the nodes into an array
            * @return {(string|Array)} - Returns a string or array of strings of the text content
            */
            parseTextNode: function (parentNode, nodeName, isMultiple) {
              var node = $(parentNode).children(nodeName);

              //If no matching nodes were found, return falsey values
              if (!node || !node.length) {

                //Return an empty array if the isMultiple flag is true
                if (isMultiple)
                  return [];
                //Return null if the isMultiple flag is false
                else
                  return null;
              }
              //If exactly one node is found and we are only expecting one, return the text content
              else if (node.length == 1 && !isMultiple) {
                return node[0].textContent.trim();
              }
              //If more than one node is found, parse into an array
              else {

                return _.map(node, function (node) {
                  return node.textContent.trim() || null;
                });

              }
            },

            /**
             * Builds the query string to send to the query engine. Iterates over each filter
             * in the filter group and adds to the query string.
             *
             * @return {string} The query string to send to Solr
             */
            getQuery: function(){

              try {

                // Although the logic used in this function is very similar to the getQuery()
                // function in the Filters collection, we can't just use
                // this.get("filters").getQuery(operator), because there are some subtle
                // differences with how queries are built using the information from
                // filterGroups, especially when the exclude attribute is set to true.

                var queryString = ""
                if(this.isEmpty()){
                  return queryString
                }

                // The operator to use between queries from filters/sub-filterGroups
                var operator = this.get("operator")

                // Helper function that adds URI encoded spaces to either side of a string
                var padString = function(string){ return "%20" + string + "%20" };
                // Helper function that appends a new part to a query fragment, using an
                // operator if the initial fragment is not empty. Returns the string as-is if
                // the newFragment is empty.
                var addQueryFragment = function(string, newFragment, operator){
                  if(!newFragment || (newFragment && newFragment.trim().length == 0) ){
                    return string
                  }
                  if(string && string.trim().length){
                    string += padString(operator)
                  }
                  string += newFragment
                  return string
                }
                // Helper function that wraps a string in parentheses
                var wrapInParentheses = function(string){
                  if (!string || (string && string.trim().length == 0)) {
                    return string
                  }
                  // TODO: We still want to wrap in parentheses in cases like "(a) OR (b)" and
                  // "a OR (b) or c" but not in cases like (a OR b)

                  // var alreadyWrapped = /^\(.*\)$/.test(string);
                  // if (alreadyWrapped) {
                  //   return string
                  // }

                  return "(" + string + ")"
                }

                // Get the list of filters that use id fields since these are used differently.
                var idFilters = this.get("filters").getIdFilters();
                // Get the remaining filters that don't contain any ID fields
                var mainFilters = this.get("filters").getNonIdFilters();

                // If the filterGroup should be excluded from the results, then don't include
                // the isPartOf filter in the part of the query that gets excluded. The
                // isPartOf filter is only meant to *include* additional results, never
                // exclude any.
                if(this.get("exclude")){
                  var isPartOfFilter = null;
                  idFilters.forEach(function(filterModel, index){
                    if(filterModel.get("fields")[0] == "isPartOf"){
                      idFilters.splice(index, 1);
                      isPartOfFilter = filterModel
                    }
                  }, this)
                }

                // Create the grouped query for the id filters (this will have the isPartOf
                // filter query if exclude is false, and will not have it if exclude is true)
                var idFilterQuery = this.get("filters").getGroupQuery(idFilters, "OR").trim();
                // Make the query fragment for all of the filters that do not contain ID fields
                var mainQuery = this.get("filters").getGroupQuery(mainFilters, operator).trim();
                // Make the query string that should be added to all catalog searches
                var categoryQuery = ""
                if(this.get("catalogSearch")){
                  categoryQuery = this.get("filters").createCatalogSearchQuery().trim()
                }
                // Make the query string for the isPartOf filter when the filter group should
                // be excluded
                var isPartOfQuery = ""
                if(isPartOfFilter){
                  isPartOfQuery = isPartOfFilter.getQuery().trim();
                }

                if(this.get("exclude")){

                  // The query is constructed like so for filter groups with exclude set to true:
                  // ( ( -( mainQuery OR idFilterQuery ) AND *:* ) OR isPartOfQuery ) AND categoryQuery
                  // Build the query string piece by piece:

                  // 1. mainQuery
                  queryString += mainQuery;
                  queryString = wrapInParentheses(queryString)
                  // 2. ( mainQuery OR idFilterQuery )
                  if (idFilterQuery.trim().length){
                    idOperator = this.get("mustMatchIds") ? "AND" : "OR"
                    queryString = addQueryFragment(queryString, idFilterQuery, idOperator)
                    queryString = wrapInParentheses(queryString)
                  }
                  // 3. -( mainQuery OR idFilterQuery )
                  if(queryString.trim().length){
                    queryString = "-" + queryString
                  }
                  // 4. ( -( mainQuery OR idFilterQuery ) AND *:* )  - see Filter model
                  // requiresPositiveClause for details on why positive clause is
                  // needed here
                  if (queryString.trim().length) {
                    queryString = addQueryFragment(queryString, "*:*", "AND")
                    queryString = wrapInParentheses(queryString)
                  }
                  // 5. ( ( -( mainQuery OR idFilterQuery ) AND *:* ) OR isPartOfQuery)
                  if (isPartOfQuery){
                    queryString = addQueryFragment(queryString, isPartOfQuery, "OR")
                    queryString = wrapInParentheses(queryString)
                  }

                  // 6. (-( mainQuery OR idFilterQuery ) AND *:* OR isPartOfQuery) AND
                  //    categoryQuery
                  queryString = addQueryFragment(queryString, categoryQuery, "AND")

                } else {

                  // The query is constructed like so for filter groups with exclude set to
                  // false: ( mainQuery OR idFilterQuery ) AND catalogQuery where
                  // idFilterQuery includes the isPartOfQuery

                  // 1. mainQuery
                  queryString += mainQuery;
                  queryString = wrapInParentheses(queryString)
                  // 2. ( mainQuery OR idFilterQuery )
                  if (idFilterQuery.trim().length) {
                    queryString = addQueryFragment(queryString, idFilterQuery, "OR")
                    queryString = wrapInParentheses(queryString)
                  }
                  // 3. ( mainQuery OR idFilterQuery ) AND catalogQuery
                  queryString = addQueryFragment(queryString, categoryQuery, "AND")

                }

                return queryString

              } catch (error) {
                console.log("Error creating a query for a Filter Group, error details:" +
                  error
                );
              }
            },

            /**
             * Overrides the default Backbone.Model.validate.function() to check if this
             * FilterGroup model has all the required values.
             *
             * @param {Object} [attrs] - A literal object of model attributes to validate.
             * @param {Object} [options] - A literal object of options for this validation
             * process
             * @return {Object} If there are errors, an object comprising error messages. If
             * no errors, returns nothing.
            */
            validate: function(){

              try {
                var errors = {};

                // The operator must be AND or OR
                if( !["AND", "OR"].includes(this.get("operator")) ){
                  //Reset the value to the default rather than return an error
                  this.set("operator", this.defaults()["operator"]);
                }

                //Exclude should always be a boolean
                if( typeof this.get("exclude") !== "boolean" ){
                  // Reset the value to the default rather than return an error
                  this.set("exclude", this.defaults().exclude);
                }

                // Validate label, icon, and description for UI Filter Groups
                if(this.get("isUIFilterType")){
                  var textAttributes = ["label", "icon", "description"];
                  // These fields should be strings
                  _.each(textAttributes, function(attr){
                    if( typeof this.get(attr) !== "string" ){
                      // Reset the value to the default rather than return an error
                      this.set(attr, this.defaults()[attr]);
                    }
                  }, this);
                  // If this filter group is not empty, and it's a UI Filter Group, then
                  // the group needs a label to be valid.
                  if(!this.isEmpty() && !this.get("label")){
                    // Set a generic label instead of returning an error
                    this.set("label", "Search")
                  }
                }

                // There must be at least one filter or filter group within each group,
                // and each filter must be valid.
                if( this.get("filters").length == 0 ){
                  errors.noFilters = "At least one filter is required."
                }
                else{
                  this.get("filters").each(function(filter){
                    if( !filter.isValid() ){
                      errors.filter = "At least one filter is invalid.";
                    }
                  });
                }

                if( Object.keys(errors).length ) {
                  return errors;
                } else {
                  return;
                }

              } catch (error) {
                console.log("Error validating a FilterGroup. Error details: " + error);
              }

            },

            /**
             * isEmpty - Checks whether this Filter Group has any filter models that are not
             * empty.
             *
             * @return {boolean} returns true if the Filter Group has Filter models that are
             * not empty
             */
            isEmpty: function(){
              try {
                var filters = this.get("filters");
                if(!filters || !filters.length){
                  return true
                }
                var subFilters = filters.getNonEmptyFilters();
                if(!subFilters || !subFilters.length){
                  return true
                } else {
                  return false
                }
              } catch (error) {
                console.log("Error checking if a Filter Group is empty. Assuming it is not." +
                " Error details: " + error);
                return false
              }
            },

            /**
             * Updates the XML DOM with the new values from the model
             * @param {object} [options] A literal object with options for this serialization
             * @return {XMLElement} An updated filterGroup XML element
            */
            updateDOM: function(options){

              try {

                // Don't serialize an empty filter group
                if(this.isEmpty()){
                  return null
                }

                // Clone the DOM if it exists
                var objectDOM = this.get("objectDOM");

                if(objectDOM){
                  objectDOM = objectDOM.cloneNode(true);
                } else {
                  // Create an XML filterGroup or definition element from scratch
                  if(!objectDOM){
                    var name = this.get("nodeName");
                    objectDOM = new DOMParser().parseFromString(
                      "<" + name + "></" + name + ">",
                      "text/xml"
                    );
                    objectDOM = $(objectDOM).find(name)[0];
                  }
                }

                $(objectDOM).empty();

                // label, description, and icon are elements that are used in Portal
                // UIFilterGroupType filterGroups only. Collection FilterGroupType filterGroups
                // do not use these elements.
                if(this.get("isUIFilterType")){

                  // Get the new values for the simple text elements
                  var filterGroupData = {
                    label: this.get("label"),
                    description: this.get("description"),
                    icon: this.get("icon")
                  }
                  // Serialize the simple text elements
                  _.map(filterGroupData, function (value, nodeName) {
                    // Don't serialize falsey values
                    if (value) {
                      // Make new sub-node
                      var nodeSerialized = objectDOM.ownerDocument.createElement(nodeName);
                      $(nodeSerialized).text(value);
                      // Append new sub-node to objectDOM
                      $(objectDOM).append(nodeSerialized);
                    }
                  });
                }

                // Serialize the filters
                var filterModels = this.get("filters").models;

                // TODO: Remove filter types depending on isUIFilterType attribute?
                // toggleFilter and choiceFilter are only allowed in Portal UIFilterGroupType.
                // nested filterGroups are only allowed in Collection FilterGroupType.

                // Don't serialize falsey values
                if (filterModels && filterModels.length) {
                  // Update each filter and append it to the DOM
                  _.each(filterModels, function (filterModel) {
                    if (filterModel) {
                      var filterModelSerialized = filterModel.updateDOM();
                    }
                    $(objectDOM).append(filterModelSerialized);
                  });
                }

                // exclude and operator are elements used only in Collection FilterGroupType
                // filterGroups. Portal UIFilterGroupType filterGroups do not use either of
                // these elements.
                if(!this.get("isUIFilterType")){
                  // The nodeName and model attribute are the same in these cases.
                  ["operator", "exclude"].forEach(function(nodeName){
                    // Don't serialize empty, null, undefined, or default values
                    var value = this.get(nodeName);
                    if( (value || value === false) && value !== this.defaults()[nodeName] ){
                      // Make new sub-node
                      var nodeSerialized = objectDOM.ownerDocument.createElement(nodeName);
                      $(nodeSerialized).text(value);
                      // Append new sub-node to objectDOM
                      $(objectDOM).append(nodeSerialized);
                    }
                  }, this);
                }

                // TODO: serialize the new <option> elements supported for Portal
                // UIFilterGroupType 1.1.0
                // if(this.get("isUIFilterType")){
                //  ... serialize options ...
                // }

                return objectDOM
              } catch (error) {
                console.error("Unable to serialize a Filter Group.", error);
                return this.get("objectDOM") || "";
              }

            }

          });

        return FilterGroup;
      });