001/*
002 *  Copyright 2018 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.runtime.model;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025
026import org.apache.commons.lang3.StringUtils;
027import org.apache.commons.lang3.tuple.ImmutablePair;
028import org.apache.commons.lang3.tuple.Pair;
029import org.slf4j.Logger;
030import org.slf4j.LoggerFactory;
031
032import org.ametys.runtime.config.DisableCondition;
033import org.ametys.runtime.config.DisableCondition.OPERATOR;
034import org.ametys.runtime.config.DisableConditions;
035import org.ametys.runtime.i18n.I18nizableText;
036import org.ametys.runtime.model.exception.UndefinedItemPathException;
037import org.ametys.runtime.model.type.ElementType;
038import org.ametys.runtime.parameter.Errors;
039import org.ametys.runtime.parameter.Validator;
040
041/**
042 * Helper class for models
043 */
044public final class ModelHelper
045{
046    private static final Logger __LOGGER = LoggerFactory.getLogger(ModelHelper.class);
047    
048    private ModelHelper()
049    {
050        // Empty constructor
051    }
052    
053    /**
054     * Checks if there is a model item at the given relative path
055     * @param path path of the model item. This path is relative to the given accessors. No matter if it is a definition or data path (with repeater entry positions)
056     * @param itemAccessors model item accessors where to search if there is a model item
057     * @return <code>true</code> if there is model item at this path, <code>false</code> otherwise
058     * @throws IllegalArgumentException if the given path is null or empty
059     */
060    public static boolean hasModelItem(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException
061    {
062        try
063        {
064            getModelItem(path, itemAccessors);
065            return true;
066        }
067        catch (UndefinedItemPathException e)
068        {
069            return false;
070        }
071    }
072    
073    /**
074     * Retrieves the model item at the given relative path
075     * @param path path of the model item to retrieve. This path is relative to the given accessors. No matter if it is a definition or data path (with repeater entry positions)
076     * @param itemAccessors model item accessors where to search the model item
077     * @return the model item
078     * @throws IllegalArgumentException if the given path is null or empty
079     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
080     */
081    public static ModelItem getModelItem(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
082    {
083        if (StringUtils.isEmpty(path))
084        {
085            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
086        }
087        
088        String definitionPath = getDefinitionPathFromDataPath(path);
089        
090        ModelItem item = null;
091        for (ModelItemAccessor accessor : itemAccessors)
092        {
093            if (accessor.hasModelItem(definitionPath))
094            {
095                item = accessor.getModelItem(definitionPath);
096                break;
097            }
098        }
099        
100        if (item != null)
101        {
102            return item;
103        }
104        else
105        {
106            String absolutePath = path;
107            if (itemAccessors.size() == 1)
108            {
109                ModelItemAccessor accessor = itemAccessors.iterator().next();
110                if (accessor instanceof ModelItemGroup)
111                {
112                    absolutePath = ((ModelItemGroup) accessor).getPath() + ModelItem.ITEM_PATH_SEPARATOR + path;
113                }
114            }
115            
116            throw new UndefinedItemPathException("Unable to retrieve the model item at path '" + absolutePath + "'. This path is not defined by the model.");
117        }
118    }
119    
120    /**
121     * Retrieve the list of successive model items represented by the given paths, indexed by path.
122     * @param paths paths of the model items to retrieve. These paths are relative to the given accessors. No matter if they are definition or data paths (with repeater entry positions)
123     * @param itemAccessors model item accessors where to search the model items
124     * @return the list of successive model items, indexed by path
125     * @throws IllegalArgumentException if one of the given paths is null or empty
126     * @throws UndefinedItemPathException if there is no item defined at one of the given paths in given item accessors
127     */
128    public static Map<String, List<ModelItem>> getAllModelItemsInPaths(Set<String> paths, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
129    {
130        Map<String, List<ModelItem>> result = new HashMap<>();
131        
132        for (String path : paths)
133        {
134            result.put(path, ModelHelper.getAllModelItemsInPath(path, itemAccessors));
135        }
136        
137        return result;
138    }
139    
140    /**
141     * Retrieve the list of successive model items represented by the given path.
142     * @param path path of the model items to retrieve. This path is relative to the given accessors. No matter if it is a definition or data path (with repeater entry positions)
143     * @param itemAccessors model item accessors where to search the model items
144     * @return the list of successive model items
145     * @throws IllegalArgumentException if the given path is null or empty
146     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
147     */
148    public static List<ModelItem> getAllModelItemsInPath(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
149    {
150        if (StringUtils.isEmpty(path))
151        {
152            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
153        }
154        
155        String definitionPath = getDefinitionPathFromDataPath(path);
156        
157        for (ModelItemAccessor itemAccessor : itemAccessors)
158        {
159            // If the current accessor has the model item at given path, no need to check in the other accessors
160            // Given containers represent only one whole model, so a path can't refer to different definitions
161            List<ModelItem> modelItemPath = _getAllModelItemsInPath(definitionPath, itemAccessor);
162            if (!modelItemPath.isEmpty())
163            {
164                return modelItemPath;
165            }
166        }
167        
168        // No path has been found in the different containers
169        throw new UndefinedItemPathException("Unable to retrieve the model items at path '" + definitionPath + "'. This path is not defined by the model.");
170    }
171    
172    private static List<ModelItem> _getAllModelItemsInPath(String definitionPath, ModelItemAccessor itemAccessor)
173    {
174        List<ModelItem> definitions = new ArrayList<>();
175
176        String[] pathSegments = StringUtils.split(definitionPath, ModelItem.ITEM_PATH_SEPARATOR);
177        
178        ModelItemAccessor currentModelItemAccessor = itemAccessor;
179        for (int i = 0; i < pathSegments.length; i++)
180        {
181            ModelItem modelItem = currentModelItemAccessor.getChild(pathSegments[i]);
182            if (modelItem != null)
183            {
184                definitions.add(modelItem);
185            }
186            else
187            {
188                return Collections.emptyList();
189            }
190            
191            if (modelItem instanceof ModelItemAccessor)
192            {
193                currentModelItemAccessor = (ModelItemAccessor) modelItem;
194            }
195        }
196        
197        return definitions;
198    }
199    
200
201    /**
202     * Determines if a container of model items contains a model item of the given type
203     * @param container the model item container
204     * @param type the type identifier to find.
205     * @return true if a model item of the given type is found
206     */
207    public static boolean hasModelItemOfType(ModelItemContainer container, String type)
208    {
209        for (ModelItem childItem : container.getModelItems())
210        {
211            if (type.equals(childItem.getType().getId()))
212            {
213                return true;
214            }
215            else if (childItem instanceof ModelItemGroup)
216            {
217                // recurse on repeater and composites
218                if (hasModelItemOfType((ModelItemGroup) childItem, type))
219                {
220                    return true;
221                }
222            }
223        }
224        
225        return false;
226    }
227    
228    /**
229     * Find all model items of the given type
230     * @param container the model item container
231     * @param type the type identifier to find.
232     * @return the list of {@link ModelItem}s of this type
233     */
234    public static List<ModelItem> findModelItemsByType(ModelItemContainer container, String type)
235    {
236        List<ModelItem> items = new ArrayList<>();
237        
238        for (ModelItem childItem : container.getModelItems())
239        {
240            if (type.equals(childItem.getType().getId()))
241            {
242                items.add(childItem);
243            }
244            else if (childItem instanceof ModelItemGroup)
245            {
246                // recurse on repeater and composites
247                items.addAll(findModelItemsByType((ModelItemGroup) childItem, type));
248            }
249        }
250        
251        return items;
252    }
253    
254    /**
255     * Retrieves the given dataPath as a definition path (without the repeaterEntry positions)
256     * @param dataPath the data path
257     * @return the definition path
258     */
259    public static String getDefinitionPathFromDataPath(String dataPath)
260    {
261        return dataPath.replaceAll("\\[[0-9]+\\]", "");
262    }
263    
264    /**
265     * Checks if this item is in a group with a switch on
266     * @param modelItem the item to check
267     * @param values all items' values to get switchers' values
268     * @return false if this item is part of a group with a switch to off, true otherwise 
269     */
270    public static boolean isGroupSwitchOn(ModelItem modelItem, Map<String, Object> values)
271    {
272        Pair<Boolean, ElementDefinition> isGroupActive = _isModelItemGroupActive(modelItem.getParent(), values);
273        if (isGroupActive.getKey())
274        {
275            return true;
276        }
277        return modelItem.equals(isGroupActive.getValue());
278    }
279    
280    private static Pair<Boolean, ElementDefinition> _isModelItemGroupActive(ModelItemGroup group, Map<String, Object> values)
281    {
282        if (group == null)
283        {
284            return new ImmutablePair<>(true, null);
285        }
286        
287        ElementDefinition<Boolean> groupSwitch = group.getSwitcher();
288        if (groupSwitch == null)
289        {
290            return _isModelItemGroupActive(group.getParent(), values);
291        }
292        
293        Object value = values.get(groupSwitch.getName());
294        
295        if (value == null)
296        {
297            value = groupSwitch.getDefaultValue();
298        }
299        
300        if (!(value instanceof Boolean))
301        {
302            throw new IllegalStateException("The switcher value of group " + group.getName() + " is null or not a boolean");
303        }
304        
305        return (Boolean) value ? _isModelItemGroupActive(group.getParent(), values) : new ImmutablePair<>(false, groupSwitch);
306    }
307    
308    /**
309     * Recursively evaluate the {@link DisableConditions} against the given values
310     * @param disableConditions the disable conditions to evaluate
311     * @param definitionAndValues the values to evaluate
312     * @param logger the logger for disable conditions evaluation logs
313     * @return true if the disable conditions are true, false otherwise
314     */
315    public static boolean evaluateDisableConditions(DisableConditions disableConditions, Map<String, DefinitionAndValue> definitionAndValues, Logger logger)
316    {
317        if (!_hasDisableConditions(disableConditions))
318        {
319            return false;
320        }
321        
322        boolean disabled;
323        boolean andOperator = disableConditions.getAssociationType() == DisableConditions.ASSOCIATION_TYPE.AND;
324        
325        // initial value depends on OR or AND associations
326        disabled = andOperator;
327        
328        for (DisableConditions subConditions : disableConditions.getSubConditions())
329        {
330            boolean result = evaluateDisableConditions(subConditions, definitionAndValues, logger);
331            disabled = andOperator ?  disabled && result : disabled || result;
332        }
333        
334        for (DisableCondition condition : disableConditions.getConditions())
335        {
336            boolean result = _evaluateCondition(condition, definitionAndValues, logger);
337            disabled = andOperator ?  disabled && result : disabled || result;
338        }
339                
340        return disabled;
341    }
342
343    private static boolean _hasDisableConditions(DisableConditions disableConditions)
344    {
345        return disableConditions != null && !disableConditions.getConditions().isEmpty() && disableConditions.getSubConditions().isEmpty();
346    }
347    
348    private static boolean _evaluateCondition(DisableCondition condition, Map<String, DefinitionAndValue> definitionAndValues, Logger logger)
349    {
350        String id = condition.getId();
351        DisableCondition.OPERATOR operator = condition.getOperator();
352        String conditionValue = condition.getValue();
353        
354        DefinitionAndValue definitionAndValue = definitionAndValues.get(id);
355        if (definitionAndValue == null || !(definitionAndValue.getDefinition() instanceof ElementDefinition))
356        {
357            logger.debug("Cannot evaluate the disable condition on the undefined element {}.\nReturning false.", id);
358            return false;
359        }
360        
361        ElementType type = ((ElementDefinition) definitionAndValue.getDefinition()).getType();
362        Object compareValue = type.castValue(conditionValue);
363        if (compareValue == null)
364        {
365            throw new IllegalStateException("Cannot convert the condition value '" + conditionValue + "' to a '" + type.getId() + "' for model item '" + id + "'");
366        }
367        
368        try
369        {
370            Object value = definitionAndValue.getValue();
371            if (value instanceof Collection)
372            {
373                if (operator == OPERATOR.EQ)
374                {
375                    for (Object v: (Collection) value)
376                    {
377                        if (_evaluateConditionValue(v, operator, compareValue))
378                        {
379                            // One entry matches
380                            return true;
381                        }
382                    }
383                
384                    return false;
385                }
386                else
387                {
388                    for (Object v: (Collection) value)
389                    {
390                        if (!_evaluateConditionValue(v, operator, compareValue))
391                        {
392                            // One entry does not match
393                            return false;
394                        }
395                    }
396                
397                    return true;
398                }
399            }
400            else
401            {
402                return _evaluateConditionValue(value, operator, compareValue);
403            }
404        }
405        catch (Exception e)
406        {
407            throw new IllegalStateException("An error occurred while comparing values in type'" + type + "' for model item '" + id + "'.", e);
408        }
409    }
410    private static boolean _evaluateConditionValue(Object value, DisableCondition.OPERATOR operator, Object compareValue)
411    {
412        if ((value == null || value instanceof Comparable) && (compareValue == null || compareValue instanceof Comparable))
413        {
414            @SuppressWarnings("unchecked")
415            Comparable<Object> comparableParameterValue = (Comparable<Object>) _emptyStringToNull(value);
416            @SuppressWarnings("unchecked")
417            Comparable<Object> comparableCompareValue = (Comparable<Object>) _emptyStringToNull(compareValue);
418            
419            int comparison;
420            if (comparableParameterValue != null && comparableCompareValue != null)
421            {
422                comparison = comparableParameterValue.compareTo(comparableCompareValue);
423            }
424            else if (comparableCompareValue != null)
425            {
426                comparison = -1; // null comparableParameterValue is considered less than non-null comparableCompareValue
427            }
428            else if (comparableParameterValue != null)
429            {
430                comparison = 1; // non-null comparableParameterValue is considered greater than null comparableCompareValue
431            }
432            else
433            {
434                comparison = 0; // both are null
435            }
436            
437            switch (operator)
438            {
439                case NEQ:
440                    return comparison != 0;
441                case GEQ:
442                    return comparison >= 0;
443                case GT:
444                    return comparison > 0;
445                case LT:
446                    return comparison < 0;
447                case LEQ:
448                    return comparison <= 0;
449                case EQ:
450                default:
451                    return comparison == 0;
452            }
453        }
454        else
455        {
456            throw new IllegalStateException("Values '" + value + "' and '" + compareValue + "' are not comparable");
457        }
458    }
459    
460    private static Object _emptyStringToNull(Object compareValue)
461    {
462        if ("".equals(compareValue))
463        {
464            return null;
465        }
466        else
467        {
468            return  compareValue;
469        }
470    }
471
472    /**
473     * Validates the given value
474     * @param definition The definition to use to validate the value
475     * @param value the value to validate
476     * @return the structure with errors information if the validation failed.
477     */
478    public static List<I18nizableText> validateValue(ElementDefinition definition, Object value)
479    {
480        return validateValue(definition, value, true);
481    }
482    
483    /**
484     * Validates the given value
485     * @param definition The definition to use to validate the value
486     * @param value the value to validate
487     * @param checkEnumerated <code>true</code> true to make sure that the item with an enumerator has its value in the enumerated values
488     * @return the structure with errors information if the validation failed.
489     * TODO NEWATTRIBUTEAPI RUNTIME-2897: remove this method to always check enumerator when validating a value 
490     */
491    public static List<I18nizableText> validateValue(ElementDefinition definition, Object value, boolean checkEnumerated)
492    {
493        List<I18nizableText> errorsList = new ArrayList<>();
494        Errors errors = new Errors();
495
496        ElementType type = definition.getType();
497        if (value != null && !type.isCompatible(value))
498        {
499            errorsList.add(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
500        }
501        
502        Validator validator = definition.getValidator();
503        if (validator != null)
504        {
505            validator.validate(value, errors);
506            if (errors.hasErrors())
507            {
508                errorsList.addAll(errors.getErrors());
509            }
510        }
511        
512        if (checkEnumerated)
513        {
514            if (definition.isMultiple())
515            {
516                Object[] values = (Object[]) value;
517                if (values != null)
518                {  
519                    for (Object singleValue : values)
520                    {
521                        _validateEnumeration(definition, singleValue, errorsList);
522                    }
523                }
524                
525            }
526            else
527            {
528                _validateEnumeration(definition, value, errorsList);
529            }
530        }
531            
532        return errorsList;
533    }
534    
535    private static void _validateEnumeration(ElementDefinition definition, Object value, List<I18nizableText> errorsList)
536    {
537        if (_checkValueEnumeration(value))
538        {
539            // Make sure that the item with an enumerator has its value in the enumerated values
540            Enumerator<Object> enumerator = definition.getEnumerator();
541            if (enumerator != null)
542            {
543                I18nizableText entry = null;
544                try
545                {
546                    entry = enumerator.getEntry(value);
547                }
548                catch (Exception e)
549                {
550                    __LOGGER.warn("An error occured while checking enumerated value '{}' for '{}'", value, enumerator, e);
551                    errorsList.add(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_LED_TO_EXCEPTION"));
552                    return;
553                }
554                
555                if (entry == null)
556                {
557                    errorsList.add(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
558                }
559            }
560        }
561    }
562    
563    private static boolean _checkValueEnumeration(Object value)
564    {
565        if (value == null)
566        {
567            return false;
568        }
569        else
570        {
571            return value instanceof String ? StringUtils.isNotEmpty((String) value) : true;
572        }
573    }
574}