( function ( mw, $ ) {
	/**
	 * View model for the filters selection and display
	 *
	 * @mixins OO.EventEmitter
	 * @mixins OO.EmitterList
	 *
	 * @constructor
	 */
	mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
		// Mixin constructor
		OO.EventEmitter.call( this );
		OO.EmitterList.call( this );

		this.groups = {};
		this.defaultParams = {};
		this.highlightEnabled = false;
		this.parameterMap = {};
		this.emptyParameterState = null;

		this.views = {};
		this.currentView = 'default';
		this.searchQuery = null;

		// Events
		this.aggregate( { update: 'filterItemUpdate' } );
		this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
	};

	/* Initialization */
	OO.initClass( mw.rcfilters.dm.FiltersViewModel );
	OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
	OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );

	/* Events */

	/**
	 * @event initialize
	 *
	 * Filter list is initialized
	 */

	/**
	 * @event update
	 *
	 * Model has been updated
	 */

	/**
	 * @event itemUpdate
	 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
	 *
	 * Filter item has changed
	 */

	/**
	 * @event highlightChange
	 * @param {boolean} Highlight feature is enabled
	 *
	 * Highlight feature has been toggled enabled or disabled
	 */

	/* Methods */

	/**
	 * Re-assess the states of filter items based on the interactions between them
	 *
	 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
	 *  method will go over the state of all items
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
		var allSelected,
			model = this,
			iterationItems = item !== undefined ? [ item ] : this.getItems();

		iterationItems.forEach( function ( checkedItem ) {
			var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
				groupModel = checkedItem.getGroupModel();

			// Check for subsets (included filters) plus the item itself:
			allCheckedItems.forEach( function ( filterItemName ) {
				var itemInSubset = model.getItemByName( filterItemName );

				itemInSubset.toggleIncluded(
					// If any of itemInSubset's supersets are selected, this item
					// is included
					itemInSubset.getSuperset().some( function ( supersetName ) {
						return ( model.getItemByName( supersetName ).isSelected() );
					} )
				);
			} );

			// Update coverage for the changed group
			if ( groupModel.isFullCoverage() ) {
				allSelected = groupModel.areAllSelected();
				groupModel.getItems().forEach( function ( filterItem ) {
					filterItem.toggleFullyCovered( allSelected );
				} );
			}
		} );

		// Check for conflicts
		// In this case, we must go over all items, since
		// conflicts are bidirectional and depend not only on
		// individual items, but also on the selected states of
		// the groups they're in.
		this.getItems().forEach( function ( filterItem ) {
			var inConflict = false,
				filterItemGroup = filterItem.getGroupModel();

			// For each item, see if that item is still conflicting
			$.each( model.groups, function ( groupName, groupModel ) {
				if ( filterItem.getGroupName() === groupName ) {
					// Check inside the group
					inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
				} else {
					// According to the spec, if two items conflict from two different
					// groups, the conflict only lasts if the groups **only have selected
					// items that are conflicting**. If a group has selected items that
					// are conflicting and non-conflicting, the scope of the result has
					// expanded enough to completely remove the conflict.

					// For example, see two groups with conflicts:
					// userExpLevel: [
					//   {
					//     name: 'experienced',
					//     conflicts: [ 'unregistered' ]
					//   }
					// ],
					// registration: [
					//   {
					//     name: 'registered',
					//   },
					//   {
					//     name: 'unregistered',
					//   }
					// ]
					// If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
					// because, inherently, 'experienced' filter only includes registered users, and so
					// both filters are in conflict with one another.
					// However, the minute we select 'registered', the scope of our results
					// has expanded to no longer have a conflict with 'experienced' filter, and
					// so the conflict is removed.

					// In our case, we need to check if the entire group conflicts with
					// the entire item's group, so we follow the above spec
					inConflict = (
						// The foreign group is in conflict with this item
						groupModel.areAllSelectedInConflictWith( filterItem ) &&
						// Every selected member of the item's own group is also
						// in conflict with the other group
						filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
							return groupModel.areAllSelectedInConflictWith( otherGroupItem );
						} )
					);
				}

				// If we're in conflict, this will return 'false' which
				// will break the loop. Otherwise, we're not in conflict
				// and the loop continues
				return !inConflict;
			} );

			// Toggle the item state
			filterItem.toggleConflicted( inConflict );
		} );
	};

	/**
	 * Get whether the model has any conflict in its items
	 *
	 * @return {boolean} There is a conflict
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
		return this.getItems().some( function ( filterItem ) {
			return filterItem.isSelected() && filterItem.isConflicted();
		} );
	};

	/**
	 * Get the first item with a current conflict
	 *
	 * @return {mw.rcfilters.dm.FilterItem} Conflicted item
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
		var conflictedItem;

		this.getItems().forEach( function ( filterItem ) {
			if ( filterItem.isSelected() && filterItem.isConflicted() ) {
				conflictedItem = filterItem;
				return false;
			}
		} );

		return conflictedItem;
	};

	/**
	 * Set filters and preserve a group relationship based on
	 * the definition given by an object
	 *
	 * @param {Array} filterGroups Filters definition
	 * @param {Object} [views] Extra views definition
	 *  Expected in the following format:
	 *  {
	 *     namespaces: {
	 *       label: 'namespaces', // Message key
	 *       trigger: ':',
	 *       groups: [
	 *         {
	 *            // Group info
	 *            name: 'namespaces' // Parameter name
	 *            title: 'namespaces' // Message key
	 *            type: 'string_options',
	 *            separator: ';',
	 *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
	 *            fullCoverage: true
	 *            items: []
	 *         }
	 *       ]
	 *     }
	 *  }
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
		var filterConflictResult, groupConflictResult,
			allViews = {},
			model = this,
			items = [],
			groupConflictMap = {},
			filterConflictMap = {},
			/*!
			 * Expand a conflict definition from group name to
			 * the list of all included filters in that group.
			 * We do this so that the direct relationship in the
			 * models are consistently item->items rather than
			 * mixing item->group with item->item.
			 *
			 * @param {Object} obj Conflict definition
			 * @return {Object} Expanded conflict definition
			 */
			expandConflictDefinitions = function ( obj ) {
				var result = {};

				$.each( obj, function ( key, conflicts ) {
					var filterName,
						adjustedConflicts = {};

					conflicts.forEach( function ( conflict ) {
						var filter;

						if ( conflict.filter ) {
							filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
							filter = model.getItemByName( filterName );

							// Rename
							adjustedConflicts[ filterName ] = $.extend(
								{},
								conflict,
								{
									filter: filterName,
									item: filter
								}
							);
						} else {
							// This conflict is for an entire group. Split it up to
							// represent each filter

							// Get the relevant group items
							model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
								// Rebuild the conflict
								adjustedConflicts[ groupItem.getName() ] = $.extend(
									{},
									conflict,
									{
										filter: groupItem.getName(),
										item: groupItem
									}
								);
							} );
						}
					} );

					result[ key ] = adjustedConflicts;
				} );

				return result;
			};

		// Reset
		this.clearItems();
		this.groups = {};
		this.views = {};

		// Clone
		filterGroups = OO.copy( filterGroups );

		// Normalize definition from the server
		filterGroups.forEach( function ( data ) {
			var i;
			// What's this information needs to be normalized
			data.whatsThis = {
				body: data.whatsThisBody,
				header: data.whatsThisHeader,
				linkText: data.whatsThisLinkText,
				url: data.whatsThisUrl
			};

			// Title is a msg-key
			data.title = data.title ? mw.msg( data.title ) : data.name;

			// Filters are given to us with msg-keys, we need
			// to translate those before we hand them off
			for ( i = 0; i < data.filters.length; i++ ) {
				data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
				data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
			}
		} );

		// Collect views
		allViews = $.extend( true, {
			'default': {
				title: mw.msg( 'rcfilters-filterlist-title' ),
				groups: filterGroups
			}
		}, views );

		// Go over all views
		$.each( allViews, function ( viewName, viewData ) {
			// Define the view
			model.views[ viewName ] = {
				name: viewData.name,
				title: viewData.title,
				trigger: viewData.trigger
			};

			// Go over groups
			viewData.groups.forEach( function ( groupData ) {
				var group = groupData.name;

				if ( !model.groups[ group ] ) {
					model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
						group,
						$.extend( true, {}, groupData, { view: viewName } )
					);
				}

				model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
				items = items.concat( model.groups[ group ].getItems() );

				// Prepare conflicts
				if ( groupData.conflicts ) {
					// Group conflicts
					groupConflictMap[ group ] = groupData.conflicts;
				}

				groupData.filters.forEach( function ( itemData ) {
					var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
					// Filter conflicts
					if ( itemData.conflicts ) {
						filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
					}
				} );
			} );
		} );

		// Add item references to the model, for lookup
		this.addItems( items );

		// Expand conflicts
		groupConflictResult = expandConflictDefinitions( groupConflictMap );
		filterConflictResult = expandConflictDefinitions( filterConflictMap );

		// Set conflicts for groups
		$.each( groupConflictResult, function ( group, conflicts ) {
			model.groups[ group ].setConflicts( conflicts );
		} );

		// Set conflicts for items
		$.each( filterConflictResult, function ( filterName, conflicts ) {
			var filterItem = model.getItemByName( filterName );
			// set conflicts for items in the group
			filterItem.setConflicts( conflicts );
		} );

		// Create a map between known parameters and their models
		$.each( this.groups, function ( group, groupModel ) {
			if (
				groupModel.getType() === 'send_unselected_if_any' ||
				groupModel.getType() === 'boolean' ||
				groupModel.getType() === 'any_value'
			) {
				// Individual filters
				groupModel.getItems().forEach( function ( filterItem ) {
					model.parameterMap[ filterItem.getParamName() ] = filterItem;
				} );
			} else if (
				groupModel.getType() === 'string_options' ||
				groupModel.getType() === 'single_option'
			) {
				// Group
				model.parameterMap[ groupModel.getName() ] = groupModel;
			}
		} );

		this.setSearch( '' );

		this.updateHighlightedState();

		// Finish initialization
		this.emit( 'initialize' );
	};

	/**
	 * Update filter view model state based on a parameter object
	 *
	 * @param {Object} params Parameters object
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
		var filtersValue;
		// For arbitrary numeric single_option values make sure the values
		// are normalized to fit within the limits
		$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
			params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
		} );

		// Update filter values
		filtersValue = this.getFiltersFromParameters( params );
		Object.keys( filtersValue ).forEach( function ( filterName ) {
			this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
		}.bind( this ) );

		// Update highlight state
		this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
			var color = params[ filterItem.getName() + '_color' ];
			if ( color ) {
				filterItem.setHighlightColor( color );
			} else {
				filterItem.clearHighlightColor();
			}
		} );
		this.updateHighlightedState();

		// Check all filter interactions
		this.reassessFilterInteractions();
	};

	/**
	 * Get a representation of an empty (falsey) parameter state
	 *
	 * @return {Object} Empty parameter state
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
		if ( !this.emptyParameterState ) {
			this.emptyParameterState = $.extend(
				true,
				{},
				this.getParametersFromFilters( {} ),
				this.getEmptyHighlightParameters()
			);
		}
		return this.emptyParameterState;
	};

	/**
	 * Get a representation of only the non-falsey parameters
	 *
	 * @param {Object} [parameters] A given parameter state to minimize. If not given the current
	 *  state of the system will be used.
	 * @return {Object} Empty parameter state
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
		var result = {};

		parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();

		// Params
		$.each( this.getEmptyParameterState(), function ( param, value ) {
			if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
				result[ param ] = parameters[ param ];
			}
		} );

		// Highlights
		Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
			if ( parameters[ param ] ) {
				// If a highlight parameter is not undefined and not null
				// add it to the result
				result[ param ] = parameters[ param ];
			}
		} );

		return result;
	};

	/**
	 * Get a representation of the full parameter list, including all base values
	 *
	 * @return {Object} Full parameter representation
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
		return $.extend(
			true,
			{},
			this.getEmptyParameterState(),
			this.getCurrentParameterState()
		);
	};

	/**
	 * Get a parameter representation of the current state of the model
	 *
	 * @param {boolean} [removeStickyParams] Remove sticky filters from final result
	 * @return {Object} Parameter representation of the current state of the model
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
		var state = this.getMinimizedParamRepresentation( $.extend(
			true,
			{},
			this.getParametersFromFilters( this.getSelectedState() ),
			this.getHighlightParameters()
		) );

		if ( removeStickyParams ) {
			state = this.removeStickyParams( state );
		}

		return state;
	};

	/**
	 * Delete sticky parameters from given object.
	 *
	 * @param {Object} paramState Parameter state
	 * @return {Object} Parameter state without sticky parameters
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
		this.getStickyParams().forEach( function ( paramName ) {
			delete paramState[ paramName ];
		} );

		return paramState;
	};

	/**
	 * Turn the highlight feature on or off
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.updateHighlightedState = function () {
		this.toggleHighlight( this.getHighlightedItems().length > 0 );
	};

	/**
	 * Get the object that defines groups by their name.
	 *
	 * @return {Object} Filter groups
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
		return this.groups;
	};

	/**
	 * Get the object that defines groups that match a certain view by their name.
	 *
	 * @param {string} [view] Requested view. If not given, uses current view
	 * @return {Object} Filter groups matching a display group
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
		var result = {};

		view = view || this.getCurrentView();

		$.each( this.groups, function ( groupName, groupModel ) {
			if ( groupModel.getView() === view ) {
				result[ groupName ] = groupModel;
			}
		} );

		return result;
	};

	/**
	 * Get an array of filters matching the given display group.
	 *
	 * @param {string} [view] Requested view. If not given, uses current view
	 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
		var groups,
			result = [];

		view = view || this.getCurrentView();

		groups = this.getFilterGroupsByView( view );

		$.each( groups, function ( groupName, groupModel ) {
			result = result.concat( groupModel.getItems() );
		} );

		return result;
	};

	/**
	 * Get the trigger for the requested view.
	 *
	 * @param {string} view View name
	 * @return {string} View trigger, if exists
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
		return ( this.views[ view ] && this.views[ view ].trigger ) || '';
	};

	/**
	 * Get the value of a specific parameter
	 *
	 * @param {string} name Parameter name
	 * @return {number|string} Parameter value
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
		return this.parameters[ name ];
	};

	/**
	 * Get the current selected state of the filters
	 *
	 * @param {boolean} [onlySelected] return an object containing only the filters with a value
	 * @return {Object} Filters selected state
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
		var i,
			items = this.getItems(),
			result = {};

		for ( i = 0; i < items.length; i++ ) {
			if ( !onlySelected || items[ i ].getValue() ) {
				result[ items[ i ].getName() ] = items[ i ].getValue();
			}
		}

		return result;
	};

	/**
	 * Get the current full state of the filters
	 *
	 * @return {Object} Filters full state
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
		var i,
			items = this.getItems(),
			result = {};

		for ( i = 0; i < items.length; i++ ) {
			result[ items[ i ].getName() ] = {
				selected: items[ i ].isSelected(),
				conflicted: items[ i ].isConflicted(),
				included: items[ i ].isIncluded()
			};
		}

		return result;
	};

	/**
	 * Get an object representing default parameters state
	 *
	 * @return {Object} Default parameter values
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
		var result = {};

		// Get default filter state
		$.each( this.groups, function ( name, model ) {
			if ( !model.isSticky() ) {
				$.extend( true, result, model.getDefaultParams() );
			}
		} );

		return result;
	};

	/**
	 * Get a parameter representation of all sticky parameters
	 *
	 * @return {Object} Sticky parameter values
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
		var result = [];

		$.each( this.groups, function ( name, model ) {
			if ( model.isSticky() ) {
				if ( model.isPerGroupRequestParameter() ) {
					result.push( name );
				} else {
					// Each filter is its own param
					result = result.concat( model.getItems().map( function ( filterItem ) {
						return filterItem.getParamName();
					} ) );
				}
			}
		} );

		return result;
	};

	/**
	 * Get a parameter representation of all sticky parameters
	 *
	 * @return {Object} Sticky parameter values
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
		var result = {};

		$.each( this.groups, function ( name, model ) {
			if ( model.isSticky() ) {
				$.extend( true, result, model.getParamRepresentation() );
			}
		} );

		return result;
	};

	/**
	 * Analyze the groups and their filters and output an object representing
	 * the state of the parameters they represent.
	 *
	 * @param {Object} [filterDefinition] An object defining the filter values,
	 *  keyed by filter names.
	 * @return {Object} Parameter state object
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
		var groupItemDefinition,
			result = {},
			groupItems = this.getFilterGroups();

		if ( filterDefinition ) {
			groupItemDefinition = {};
			// Filter definition is "flat", but in effect
			// each group needs to tell us its result based
			// on the values in it. We need to split this list
			// back into groupings so we can "feed" it to the
			// loop below, and we need to expand it so it includes
			// all filters (set to false)
			this.getItems().forEach( function ( filterItem ) {
				groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
				groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
			} );
		}

		$.each( groupItems, function ( group, model ) {
			$.extend(
				result,
				model.getParamRepresentation(
					groupItemDefinition ?
						groupItemDefinition[ group ] : null
				)
			);
		} );

		return result;
	};

	/**
	 * This is the opposite of the #getParametersFromFilters method; this goes over
	 * the given parameters and translates into a selected/unselected value in the filters.
	 *
	 * @param {Object} params Parameters query object
	 * @return {Object} Filter state object
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
		var groupMap = {},
			model = this,
			result = {};

		// Go over the given parameters, break apart to groupings
		// The resulting object represents the group with its parameter
		// values. For example:
		// {
		//    group1: {
		//       param1: "1",
		//       param2: "0",
		//       param3: "1"
		//    },
		//    group2: "param4|param5"
		// }
		$.each( params, function ( paramName, paramValue ) {
			var groupName,
				itemOrGroup = model.parameterMap[ paramName ];

			if ( itemOrGroup ) {
				groupName = itemOrGroup instanceof mw.rcfilters.dm.FilterItem ?
					itemOrGroup.getGroupName() : itemOrGroup.getName();

				groupMap[ groupName ] = groupMap[ groupName ] || {};
				groupMap[ groupName ][ paramName ] = paramValue;
			}
		} );

		// Go over all groups, so we make sure we get the complete output
		// even if the parameters don't include a certain group
		$.each( this.groups, function ( groupName, groupModel ) {
			result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
		} );

		return result;
	};

	/**
	 * Get the highlight parameters based on current filter configuration
	 *
	 * @return {Object} Object where keys are `<filter name>_color` and values
	 *                  are the selected highlight colors.
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
		var highlightEnabled = this.isHighlightEnabled(),
			result = {};

		this.getItems().forEach( function ( filterItem ) {
			if ( filterItem.isHighlightSupported() ) {
				result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
					filterItem.getHighlightColor() :
					null;
			}
		} );

		return result;
	};

	/**
	 * Get an object representing the complete empty state of highlights
	 *
	 * @return {Object} Object containing all the highlight parameters set to their negative value
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
		var result = {};

		this.getItems().forEach( function ( filterItem ) {
			if ( filterItem.isHighlightSupported() ) {
				result[ filterItem.getName() + '_color' ] = null;
			}
		} );

		return result;
	};

	/**
	 * Get an array of currently applied highlight colors
	 *
	 * @return {string[]} Currently applied highlight colors
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
		var result = [];

		if ( this.isHighlightEnabled() ) {
			this.getHighlightedItems().forEach( function ( filterItem ) {
				var color = filterItem.getHighlightColor();

				if ( result.indexOf( color ) === -1 ) {
					result.push( color );
				}
			} );
		}

		return result;
	};

	/**
	 * Sanitize value group of a string_option groups type
	 * Remove duplicates and make sure to only use valid
	 * values.
	 *
	 * @private
	 * @param {string} groupName Group name
	 * @param {string[]} valueArray Array of values
	 * @return {string[]} Array of valid values
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
		var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
			return filterItem.getParamName();
		} );

		return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
	};

	/**
	 * Check whether no visible filter is selected.
	 *
	 * Filter groups that are hidden or sticky are not shown in the
	 * active filters area and therefore not included in this check.
	 *
	 * @return {boolean} No visible filter is selected
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
		// Check if there are either any selected items or any items
		// that have highlight enabled
		return !this.getItems().some( function ( filterItem ) {
			var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
				active = ( filterItem.isSelected() || filterItem.isHighlighted() );
			return visible && active;
		} );
	};

	/**
	 * Check whether the invert state is a valid one. A valid invert state is one where
	 * there are actual namespaces selected.
	 *
	 * This is done to compare states to previous ones that may have had the invert model
	 * selected but effectively had no namespaces, so are not effectively different than
	 * ones where invert is not selected.
	 *
	 * @return {boolean} Invert is effectively selected
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
		return this.getInvertModel().isSelected() &&
			this.findSelectedItems().some( function ( itemModel ) {
				return itemModel.getGroupModel().getName() === 'namespace';
			} );
	};

	/**
	 * Get the item that matches the given name
	 *
	 * @param {string} name Filter name
	 * @return {mw.rcfilters.dm.FilterItem} Filter item
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
		return this.getItems().filter( function ( item ) {
			return name === item.getName();
		} )[ 0 ];
	};

	/**
	 * Set all filters to false or empty/all
	 * This is equivalent to display all.
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
		this.getItems().forEach( function ( filterItem ) {
			if ( !filterItem.getGroupModel().isSticky() ) {
				this.toggleFilterSelected( filterItem.getName(), false );
			}
		}.bind( this ) );
	};

	/**
	 * Toggle selected state of one item
	 *
	 * @param {string} name Name of the filter item
	 * @param {boolean} [isSelected] Filter selected state
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
		var item = this.getItemByName( name );

		if ( item ) {
			item.toggleSelected( isSelected );
		}
	};

	/**
	 * Toggle selected state of items by their names
	 *
	 * @param {Object} filterDef Filter definitions
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
		Object.keys( filterDef ).forEach( function ( name ) {
			this.toggleFilterSelected( name, filterDef[ name ] );
		}.bind( this ) );
	};

	/**
	 * Get a group model from its name
	 *
	 * @param {string} groupName Group name
	 * @return {mw.rcfilters.dm.FilterGroup} Group model
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
		return this.groups[ groupName ];
	};

	/**
	 * Get all filters within a specified group by its name
	 *
	 * @param {string} groupName Group name
	 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
		return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
	};

	/**
	 * Find items whose labels match the given string
	 *
	 * @param {string} query Search string
	 * @param {boolean} [returnFlat] Return a flat array. If false, the result
	 *  is an object whose keys are the group names and values are an array of
	 *  filters per group. If set to true, returns an array of filters regardless
	 *  of their groups.
	 * @return {Object} An object of items to show
	 *  arranged by their group names
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
		var i, searchIsEmpty,
			groupTitle,
			result = {},
			flatResult = [],
			view = this.getViewByTrigger( query.substr( 0, 1 ) ),
			items = this.getFiltersByView( view );

		// Normalize so we can search strings regardless of case and view
		query = query.trim().toLowerCase();
		if ( view !== 'default' ) {
			query = query.substr( 1 );
		}
		// Trim again to also intercept cases where the spaces were after the trigger
		// eg: '#   str'
		query = query.trim();

		// Check if the search if actually empty; this can be a problem when
		// we use prefixes to denote different views
		searchIsEmpty = query.length === 0;

		// item label starting with the query string
		for ( i = 0; i < items.length; i++ ) {
			if (
				searchIsEmpty ||
				items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
				(
					// For tags, we want the parameter name to be included in the search
					view === 'tags' &&
					items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
				)
			) {
				result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
				result[ items[ i ].getGroupName() ].push( items[ i ] );
				flatResult.push( items[ i ] );
			}
		}

		if ( $.isEmptyObject( result ) ) {
			// item containing the query string in their label, description, or group title
			for ( i = 0; i < items.length; i++ ) {
				groupTitle = items[ i ].getGroupModel().getTitle();
				if (
					searchIsEmpty ||
					items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
					items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
					groupTitle.toLowerCase().indexOf( query ) > -1 ||
					(
						// For tags, we want the parameter name to be included in the search
						view === 'tags' &&
						items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
					)
				) {
					result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
					result[ items[ i ].getGroupName() ].push( items[ i ] );
					flatResult.push( items[ i ] );
				}
			}
		}

		return returnFlat ? flatResult : result;
	};

	/**
	 * Get items that are highlighted
	 *
	 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
		return this.getItems().filter( function ( filterItem ) {
			return filterItem.isHighlightSupported() &&
				filterItem.getHighlightColor();
		} );
	};

	/**
	 * Get items that allow highlights even if they're not currently highlighted
	 *
	 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
		return this.getItems().filter( function ( filterItem ) {
			return filterItem.isHighlightSupported();
		} );
	};

	/**
	 * Get all selected items
	 *
	 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.findSelectedItems = function () {
		var allSelected = [];

		$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
			allSelected = allSelected.concat( groupModel.findSelectedItems() );
		} );

		return allSelected;
	};

	/**
	 * Get the current view
	 *
	 * @return {string} Current view
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
		return this.currentView;
	};

	/**
	 * Get the label for the current view
	 *
	 * @param {string} viewName View name
	 * @return {string} Label for the current view
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
		viewName = viewName || this.getCurrentView();

		return this.views[ viewName ] && this.views[ viewName ].title;
	};

	/**
	 * Get the view that fits the given trigger
	 *
	 * @param {string} trigger Trigger
	 * @return {string} Name of view
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
		var result = 'default';

		$.each( this.views, function ( name, data ) {
			if ( data.trigger === trigger ) {
				result = name;
			}
		} );

		return result;
	};

	/**
	 * Return a version of the given string that is without any
	 * view triggers.
	 *
	 * @param {string} str Given string
	 * @return {string} Result
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
		if ( this.getViewFromString( str ) !== 'default' ) {
			str = str.substr( 1 );
		}

		return str;
	};

	/**
	 * Get the view from the given string by a trigger, if it exists
	 *
	 * @param {string} str Given string
	 * @return {string} View name
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getViewFromString = function ( str ) {
		return this.getViewByTrigger( str.substr( 0, 1 ) );
	};

	/**
	 * Set the current search for the system.
	 * This also dictates what items and groups are visible according
	 * to the search in #findMatches
	 *
	 * @param {string} searchQuery Search query, including triggers
	 * @fires searchChange
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
		var visibleGroups, visibleGroupNames;

		if ( this.searchQuery !== searchQuery ) {
			// Check if the view changed
			this.switchView( this.getViewFromString( searchQuery ) );

			visibleGroups = this.findMatches( searchQuery );
			visibleGroupNames = Object.keys( visibleGroups );

			// Update visibility of items and groups
			$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
				// Check if the group is visible at all
				groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
				groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
			} );

			this.searchQuery = searchQuery;
			this.emit( 'searchChange', this.searchQuery );
		}
	};

	/**
	 * Get the current search
	 *
	 * @return {string} Current search query
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getSearch = function () {
		return this.searchQuery;
	};

	/**
	 * Switch the current view
	 *
	 * @private
	 * @param {string} view View name
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
		if ( this.views[ view ] && this.currentView !== view ) {
			this.currentView = view;
		}
	};

	/**
	 * Toggle the highlight feature on and off.
	 * Propagate the change to filter items.
	 *
	 * @param {boolean} enable Highlight should be enabled
	 * @fires highlightChange
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
		enable = enable === undefined ? !this.highlightEnabled : enable;

		if ( this.highlightEnabled !== enable ) {
			this.highlightEnabled = enable;
			this.emit( 'highlightChange', this.highlightEnabled );
		}
	};

	/**
	 * Check if the highlight feature is enabled
	 * @return {boolean}
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
		return !!this.highlightEnabled;
	};

	/**
	 * Toggle the inverted namespaces property on and off.
	 * Propagate the change to namespace filter items.
	 *
	 * @param {boolean} enable Inverted property is enabled
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
		this.toggleFilterSelected( this.getInvertModel().getName(), enable );
	};

	/**
	 * Get the model object that represents the 'invert' filter
	 *
	 * @return {mw.rcfilters.dm.FilterItem}
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () {
		return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
	};

	/**
	 * Set highlight color for a specific filter item
	 *
	 * @param {string} filterName Name of the filter item
	 * @param {string} color Selected color
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
		this.getItemByName( filterName ).setHighlightColor( color );
	};

	/**
	 * Clear highlight for a specific filter item
	 *
	 * @param {string} filterName Name of the filter item
	 */
	mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
		this.getItemByName( filterName ).clearHighlightColor();
	};

}( mediaWiki, jQuery ) );
