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.commons.lang3.StringUtils;
028import org.apache.commons.lang3.tuple.ImmutablePair;
029import org.apache.commons.lang3.tuple.Pair;
030import org.slf4j.Logger;
031import org.slf4j.LoggerFactory;
032
033import org.ametys.runtime.i18n.I18nizableText;
034import org.ametys.runtime.model.disableconditions.DisableCondition;
035import org.ametys.runtime.model.disableconditions.DisableConditions;
036import org.ametys.runtime.model.exception.UndefinedItemPathException;
037import org.ametys.runtime.model.type.ElementType;
038import org.ametys.runtime.parameter.ValidationResult;
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     * Retrieves all model items of given accessors.
122     * @param itemAccessors The model items accessors
123     * @return the model items
124     * @throws IllegalArgumentException if some models define a model item with the same name and this model item does not come from a common ancestor 
125     */
126    public static Collection<? extends ModelItem> getModelItems(Collection<? extends ModelItemAccessor> itemAccessors)
127    {
128        Map<String, ModelItem> items = new LinkedHashMap<>();
129
130        for (ModelItemAccessor itemContainer : itemAccessors)
131        {
132            for (ModelItem currentItem : itemContainer.getModelItems())
133            {
134                final String currentItemName = currentItem.getName();
135                if (items.containsKey(currentItemName))
136                {
137                    ModelItem existingItem = items.get(currentItemName);
138                    
139                    if (!currentItem.getModel().equals(existingItem.getModel()))
140                    {
141                        // The definition does not provide from a common ancestor
142                        throw new IllegalArgumentException("The model item '" + currentItemName + "' defined in model '" + currentItem.getModel().getId() + "' is already defined in another model '"
143                                + existingItem.getModel().getId() + "'");
144                    }
145                    continue;
146                }
147
148                items.put(currentItemName, currentItem);
149            }
150        }
151
152        return items.values();
153    }
154    
155    /**
156     * Retrieve the list of successive model items represented by the given paths, indexed by path.
157     * @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)
158     * @param itemAccessors model item accessors where to search the model items
159     * @return the list of successive model items, indexed by path
160     * @throws IllegalArgumentException if one of the given paths is null or empty
161     * @throws UndefinedItemPathException if there is no item defined at one of the given paths in given item accessors
162     */
163    public static Map<String, List<ModelItem>> getAllModelItemsInPaths(Set<String> paths, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
164    {
165        Map<String, List<ModelItem>> result = new HashMap<>();
166        
167        for (String path : paths)
168        {
169            result.put(path, ModelHelper.getAllModelItemsInPath(path, itemAccessors));
170        }
171        
172        return result;
173    }
174    
175    /**
176     * Retrieve the list of successive model items represented by the given path.
177     * @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)
178     * @param itemAccessors model item accessors where to search the model items
179     * @return the list of successive model items
180     * @throws IllegalArgumentException if the given path is null or empty
181     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
182     */
183    public static List<ModelItem> getAllModelItemsInPath(String path, Collection<? extends ModelItemAccessor> itemAccessors) throws IllegalArgumentException, UndefinedItemPathException
184    {
185        if (StringUtils.isEmpty(path))
186        {
187            throw new IllegalArgumentException("Unable to retrieve the model item at the given path. This path is empty.");
188        }
189        
190        String definitionPath = getDefinitionPathFromDataPath(path);
191        
192        for (ModelItemAccessor itemAccessor : itemAccessors)
193        {
194            // If the current accessor has the model item at given path, no need to check in the other accessors
195            // Given containers represent only one whole model, so a path can't refer to different definitions
196            List<ModelItem> modelItemPath = _getAllModelItemsInPath(definitionPath, itemAccessor);
197            if (!modelItemPath.isEmpty())
198            {
199                return modelItemPath;
200            }
201        }
202        
203        // No path has been found in the different containers
204        throw new UndefinedItemPathException("Unable to retrieve the model items at path '" + definitionPath + "'. This path is not defined by the model.");
205    }
206    
207    private static List<ModelItem> _getAllModelItemsInPath(String definitionPath, ModelItemAccessor itemAccessor)
208    {
209        List<ModelItem> definitions = new ArrayList<>();
210
211        String[] pathSegments = StringUtils.split(definitionPath, ModelItem.ITEM_PATH_SEPARATOR);
212        
213        ModelItemAccessor currentModelItemAccessor = itemAccessor;
214        for (int i = 0; i < pathSegments.length; i++)
215        {
216            ModelItem modelItem = currentModelItemAccessor.getChild(pathSegments[i]);
217            if (modelItem != null)
218            {
219                definitions.add(modelItem);
220            }
221            else
222            {
223                return Collections.emptyList();
224            }
225            
226            if (modelItem instanceof ModelItemAccessor)
227            {
228                currentModelItemAccessor = (ModelItemAccessor) modelItem;
229            }
230        }
231        
232        return definitions;
233    }
234    
235
236    /**
237     * Determines if a container of model items contains a model item of the given type
238     * @param container the model item container
239     * @param type the type identifier to find.
240     * @return true if a model item of the given type is found
241     */
242    public static boolean hasModelItemOfType(ModelItemAccessor container, String type)
243    {
244        for (ModelItem childItem : container.getModelItems())
245        {
246            if (type.equals(childItem.getType().getId()))
247            {
248                return true;
249            }
250            else if (childItem instanceof ModelItemGroup)
251            {
252                // recurse on repeater and composites
253                if (hasModelItemOfType((ModelItemGroup) childItem, type))
254                {
255                    return true;
256                }
257            }
258        }
259        
260        return false;
261    }
262    
263    /**
264     * Find all model items of the given type
265     * @param container the model item container
266     * @param type the type identifier to find.
267     * @return the list of {@link ModelItem}s of this type
268     */
269    public static List<ModelItem> findModelItemsByType(ModelItemAccessor container, String type)
270    {
271        List<ModelItem> items = new ArrayList<>();
272        
273        for (ModelItem childItem : container.getModelItems())
274        {
275            if (type.equals(childItem.getType().getId()))
276            {
277                items.add(childItem);
278            }
279            else if (childItem instanceof ModelItemGroup)
280            {
281                // recurse on repeater and composites
282                items.addAll(findModelItemsByType((ModelItemGroup) childItem, type));
283            }
284        }
285        
286        return items;
287    }
288    
289    /**
290     * Retrieves the given dataPath as a definition path (without the repeaterEntry positions)
291     * @param dataPath the data path
292     * @return the definition path
293     */
294    public static String getDefinitionPathFromDataPath(String dataPath)
295    {
296        return dataPath.replaceAll("\\[[0-9]+\\]", "");
297    }
298    
299    /**
300     * Checks if this item is in a group with a switch on
301     * @param modelItem the item to check
302     * @param values all items' values to get switchers' values
303     * @return false if this item is part of a group with a switch to off, true otherwise 
304     */
305    public static boolean isGroupSwitchOn(ModelItem modelItem, Map<String, Object> values)
306    {
307        Pair<Boolean, ElementDefinition> isGroupActive = _isModelItemGroupActive(modelItem.getParent(), values);
308        if (isGroupActive.getKey())
309        {
310            return true;
311        }
312        return modelItem.equals(isGroupActive.getValue());
313    }
314    
315    private static Pair<Boolean, ElementDefinition> _isModelItemGroupActive(ModelItemGroup group, Map<String, Object> values)
316    {
317        if (group == null)
318        {
319            return new ImmutablePair<>(true, null);
320        }
321        
322        ElementDefinition<Boolean> groupSwitch = group.getSwitcher();
323        if (groupSwitch == null)
324        {
325            return _isModelItemGroupActive(group.getParent(), values);
326        }
327        
328        Object value = values.get(groupSwitch.getName());
329        
330        if (value == null)
331        {
332            value = groupSwitch.getDefaultValue();
333        }
334        
335        if (!(value instanceof Boolean))
336        {
337            throw new IllegalStateException("The switcher value of group " + group.getName() + " is null or not a boolean");
338        }
339        
340        return (Boolean) value ? _isModelItemGroupActive(group.getParent(), values) : new ImmutablePair<>(false, groupSwitch);
341    }
342    
343    /**
344     * Retrieves the absolute path of the condition
345     * @param condition the condition
346     * @param relativePath the relative path for computing condition absolute path (path of the model item defining the condition, or path of an evaluated data)
347     * @return the absolute path of the condition
348     * @throws IllegalArgumentException if the path of the condition can not be computed from the given relative path
349     */
350    public static String getDisableConditionAbsolutePath(DisableCondition condition, String relativePath) throws IllegalArgumentException
351    {
352        String[] conditionPathSegments = StringUtils.split(condition.getId(), ModelItem.ITEM_PATH_SEPARATOR);
353        String[] relativePathSegments = StringUtils.split(relativePath, ModelItem.ITEM_PATH_SEPARATOR);
354        
355        int nbParentSegmentInConditionPath = 0;
356        String conditionPathSegment = conditionPathSegments[nbParentSegmentInConditionPath];
357        while ("..".equals(conditionPathSegment))
358        {
359            nbParentSegmentInConditionPath++;
360            conditionPathSegment = conditionPathSegments[nbParentSegmentInConditionPath];
361        }
362        
363        int prefixLength = relativePathSegments.length - (nbParentSegmentInConditionPath + 1);
364        if (prefixLength < 0)
365        {
366            String message = String.format("Unable to retrieve the condition absolute path from condition '%s' and relative path '%s'. The condition path has to many relative parents segments.", condition.getId(), relativePath);
367            throw new IllegalArgumentException(message);
368        }
369        
370        // Take the first segments of relative path
371        String prefix = StringUtils.join(relativePathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, relativePathSegments.length - (nbParentSegmentInConditionPath + 1));
372        // and the last ones of the condition path
373        String suffix = StringUtils.join(conditionPathSegments, ModelItem.ITEM_PATH_SEPARATOR, nbParentSegmentInConditionPath, conditionPathSegments.length);
374        
375        return StringUtils.isNotBlank(prefix) ? prefix + ModelItem.ITEM_PATH_SEPARATOR + suffix : suffix;
376    }
377    
378    /**
379     * Check if the given model item contains non empty disable conditions 
380     * @param modelItem the model item to check
381     * @return <code>true</code> if a non empty condition has been found, <code>false</code> otherwise
382     */
383    public static boolean hasDisableConditions(ModelItem modelItem)
384    {
385        return hasDisableConditions(modelItem.getDisableConditions());
386    }
387    
388    /**
389     * Check if there is a non empty condition in the given conditions
390     * @param disableConditions the disable conditions to check
391     * @return <code>true</code> if a non empty condition has been found, <code>false</code> otherwise
392     */
393    public static boolean hasDisableConditions(DisableConditions disableConditions)
394    {
395        if (disableConditions == null)
396        {
397            return false;
398        }
399
400        if (!disableConditions.getConditions().isEmpty())
401        {
402            // a condition has been found
403            return true;
404        }
405        
406        return disableConditions.getSubConditions()
407                                .stream()
408                                // search for a non empty sub condition
409                                .anyMatch(ModelHelper::hasDisableConditions);
410    }
411
412    /**
413     * Validates the given value
414     * @param definition The definition to use to validate the value
415     * @param value the value to validate
416     * @return the structure with errors information if the validation failed.
417     */
418    public static ValidationResult validateValue(ElementDefinition definition, Object value)
419    {
420        return validateValue(definition, value, true);
421    }
422    
423    /**
424     * Validates the given value
425     * @param definition The definition to use to validate the value
426     * @param value the value to validate
427     * @param checkEnumerated <code>true</code> true to make sure that the item with an enumerator has its value in the enumerated values
428     * @return the structure with errors information if the validation failed.
429     * TODO NEWATTRIBUTEAPI RUNTIME-2897: remove this method to always check enumerator when validating a value 
430     */
431    public static ValidationResult validateValue(ElementDefinition definition, Object value, boolean checkEnumerated)
432    {
433        ValidationResult result = new ValidationResult();
434
435        ElementType type = definition.getType();
436        if (value != null && !type.isCompatible(value))
437        {
438            result.addError(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
439        }
440        else
441        {
442            Validator validator = definition.getValidator();
443            if (validator != null)
444            {
445                result.addResult(validator.validate(value));
446            }
447            
448            if (checkEnumerated)
449            {
450                if (definition.isMultiple())
451                {
452                    Object[] values = (Object[]) value;
453                    if (values != null)
454                    {  
455                        for (Object singleValue : values)
456                        {
457                            result.addResult(_validateEnumeration(definition, singleValue));
458                        }
459                    }
460                    
461                }
462                else
463                {
464                    result.addResult(_validateEnumeration(definition, value));
465                }
466            }
467        }
468        
469        return result;
470    }
471    
472    private static ValidationResult _validateEnumeration(ElementDefinition definition, Object value)
473    {
474        ValidationResult result = new ValidationResult();
475        
476        if (_checkValueEnumeration(value))
477        {
478            // Make sure that the item with an enumerator has its value in the enumerated values
479            Enumerator<Object> enumerator = definition.getEnumerator();
480            if (enumerator != null)
481            {
482                try
483                {
484                    I18nizableText entry = enumerator.getEntry(value);
485                    if (entry == null)
486                    {
487                        result.addError(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_NOT_ALLOWED"));
488                    }
489                }
490                catch (Exception e)
491                {
492                    __LOGGER.warn("An error occured while checking enumerated value '{}' for '{}'", value, enumerator, e);
493                    result.addError(new I18nizableText("plugin.core", "PLUGINS_CORE_ELEMENT_DEFINITION_VALUE_LED_TO_EXCEPTION"));
494                }
495            }
496        }
497        
498        return result;
499    }
500    
501    private static boolean _checkValueEnumeration(Object value)
502    {
503        if (value == null)
504        {
505            return false;
506        }
507        else
508        {
509            return value instanceof String ? StringUtils.isNotEmpty((String) value) : true;
510        }
511    }
512}