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