001/*
002 *  Copyright 2024 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.disableconditions;
017
018import java.util.Arrays;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023
024import org.apache.avalon.framework.component.Component;
025import org.apache.commons.lang3.StringUtils;
026
027import org.ametys.runtime.model.ElementDefinition;
028import org.ametys.runtime.model.ModelHelper;
029import org.ametys.runtime.model.ModelItem;
030import org.ametys.runtime.model.ModelItemAccessor;
031import org.ametys.runtime.model.disableconditions.DisableCondition.OPERATOR;
032import org.ametys.runtime.model.exception.BadItemTypeException;
033import org.ametys.runtime.model.exception.UndefinedItemPathException;
034import org.ametys.runtime.model.type.ElementType;
035import org.ametys.runtime.plugin.component.AbstractLogEnabled;
036
037/**
038 * Abstract evaluator for {@link DisableConditions}
039 * @param <T> Type of object holding the data to evaluate
040 */
041public abstract class AbstractDisableConditionsEvaluator<T extends Object> extends AbstractLogEnabled implements Component, DisableConditionsEvaluator<T>
042{
043    public boolean evaluateDisableConditions(ModelItem definition, String dataPath, Map<String, Object> values) throws UndefinedItemPathException, BadItemTypeException
044    {
045        return evaluateDisableConditions(definition, definition.getDisableConditions(), dataPath, Optional.empty(), values, Optional.empty(), new HashMap<>());
046    }
047    
048    public boolean evaluateDisableConditions(ModelItem definition, String dataPath, T object) throws UndefinedItemPathException, BadItemTypeException
049    {
050        return evaluateDisableConditions(definition, definition.getDisableConditions(), dataPath, Optional.empty(), Map.of(), Optional.ofNullable(object), new HashMap<>());
051    }
052    
053    public boolean evaluateDisableConditions(ModelItem definition, String dataPath, Optional<String> oldDataPath, Map<String, Object> values, T object, Map<String, Object> contextualParameters) throws UndefinedItemPathException, BadItemTypeException
054    {
055        return evaluateDisableConditions(definition, definition.getDisableConditions(), dataPath, oldDataPath, values, Optional.ofNullable(object), contextualParameters);
056    }
057    
058    /**
059     * Recursively evaluate the given {@link DisableConditions} against the given values
060     * @param definition the definition of the evaluated data
061     * @param disableConditions the conditions to evaluate
062     * @param dataPath the path of the evaluated data. Needed to get the value to compare as condition ids are relative to this one
063     * @param oldDataPath the old path of the evaluated data. Needed to get stored value if the data has been moved
064     * @param values values to check conditions on 
065     * @param object the object holding the data to evaluate and the condition value
066     * @param contextualParameters the contextual parameters
067     * @return <code>true</code> if the disable conditions are <code>true</code>, <code>false</code> otherwise
068     * @throws UndefinedItemPathException If no item is found corresponding to one of the given conditions
069     * @throws BadItemTypeException If the item referenced by one of the conditions is not an element
070     */
071    protected boolean evaluateDisableConditions(ModelItem definition, DisableConditions disableConditions, String dataPath, Optional<String> oldDataPath, Map<String, Object> values, Optional<T> object, Map<String, Object> contextualParameters) throws UndefinedItemPathException, BadItemTypeException
072    {
073        if (!ModelHelper.hasDisableConditions(disableConditions))
074        {
075            return false;
076        }
077        
078        boolean andOperator = disableConditions.getAssociationType() == DisableConditions.ASSOCIATION_TYPE.AND;
079        
080        // initial value depends on OR or AND associations
081        boolean disabled = andOperator;
082        
083        for (DisableConditions subConditions : disableConditions.getSubConditions())
084        {
085            boolean result = evaluateDisableConditions(definition, subConditions, dataPath, oldDataPath, values, object, contextualParameters);
086            if (_resultIsSufficient(andOperator, result))
087            {
088                return result;
089            }
090            else
091            {
092                disabled = andOperator ? disabled && result : disabled || result;
093            }
094        }
095        
096        for (DisableCondition condition : disableConditions.getConditions())
097        {
098            boolean result = evaluateDisableCondition(definition, condition, dataPath, oldDataPath, values, object, contextualParameters);
099            if (_resultIsSufficient(andOperator, result))
100            {
101                return result;
102            }
103            else
104            {
105                disabled = andOperator ? disabled && result : disabled || result;
106            }
107        }
108                
109        return disabled;
110    }
111    
112    /**
113     * Check if the given result is sufficient to evaluate the current disable condition 
114     * @param andOperator the conditions operator
115     * @param result the result of the current condition
116     * @return <code>true</code> if the result of evaluation is sufficient, <code>false</code> otherwise
117     */
118    protected boolean _resultIsSufficient(boolean andOperator, boolean result)
119    {
120        return andOperator && !result || !andOperator && result;
121    }
122
123    /**
124     * Evaluate the given {@link DisableCondition} against the given values
125     * @param definition the definition of the evaluated data
126     * @param condition the condition to evaluate
127     * @param dataPath the path of the evaluated data. Needed to get the value to compare as condition ids are relative to this one
128     * @param oldDataPath the old path of the evaluated data. Needed to get stored value if the data has been moved
129     * @param values values to check conditions on
130     * @param object the object holding the data to evaluate and the condition value
131     * @param contextualParameters the contextual parameters
132     * @return <code>true</code> if the disable condition is <code>true</code>, <code>false</code> otherwise
133     * @throws UndefinedItemPathException If no item is found corresponding to the given condition
134     * @throws BadItemTypeException If the item referenced by the condition is not an element
135     */
136    protected boolean evaluateDisableCondition(ModelItem definition, DisableCondition condition, String dataPath, Optional<String> oldDataPath, Map<String, Object> values, Optional<T> object, Map<String, Object> contextualParameters)  throws UndefinedItemPathException, BadItemTypeException
137    {
138        DefinitionAndValue definitionAndValue = _getConditionDefinitionAndValue(definition, condition, dataPath, oldDataPath, values, object, contextualParameters);
139        ElementDefinition conditionDefinition = definitionAndValue.definition();
140        ElementType type = conditionDefinition.getType();
141        String valueFromCondition = condition.getValue();
142
143        try
144        {
145            Object typedvalueFromCondition = type.castValue(valueFromCondition);
146            Object value = definitionAndValue.value();
147            
148            DisableCondition.OPERATOR operator = condition.getOperator();
149            if (value != null && value.getClass().isArray())
150            {
151                if (operator == OPERATOR.EQ)
152                {
153                    return Arrays.stream((Object[]) value)
154                                 .anyMatch(v -> evaluateDisableConditionValue(typedvalueFromCondition, operator, v));
155                }
156                else
157                {
158                    return Arrays.stream((Object[]) value)
159                                 .allMatch(v -> evaluateDisableConditionValue(typedvalueFromCondition, operator, v));
160                }
161            }
162            else
163            {
164                return evaluateDisableConditionValue(typedvalueFromCondition, operator, value);
165            }
166        }
167        catch (BadItemTypeException e)
168        {
169            throw new IllegalStateException("Cannot convert the condition value '" + valueFromCondition + "' to a '" + type.getId() + "' for model item '" + condition.getId() + "'", e);
170        }
171        catch (Exception e)
172        {
173            throw new IllegalStateException("An error occurred while comparing values in type '" + type.getId() + "' for model item '" + condition.getId() + "'.", e);
174        }
175    }
176    
177    /**
178     * Retrieve the value corresponding to the identifier of the condition, and its type
179     * @param object the object holding the data to evaluate and the condition value
180     * @param definition the definition containing the condition
181     * @param condition the condition 
182     * @param dataPath the path of the evaluated data
183     * @param oldDataPath the old path of the evaluated data. Needed to get stored value if the data has been moved
184     * @param values values to check conditions on
185     * @param contextualParameters the contextual parameters
186     * @return the {@link DefinitionAndValue} corresponding to the condition identifier
187     * @throws UndefinedItemPathException If no item is found with the given conditionId
188     * @throws BadItemTypeException If the item referenced by the conditionId is not an element
189     */
190    protected DefinitionAndValue _getConditionDefinitionAndValue(ModelItem definition, DisableCondition condition, String dataPath, Optional<String> oldDataPath, Map<String, Object> values, Optional<T> object, Map<String, Object> contextualParameters) throws UndefinedItemPathException, BadItemTypeException
191    {
192        String conditionAbsoluteDataPath = ModelHelper.getDisableConditionAbsolutePath(condition, dataPath);
193        Optional<String> oldConditionAbsoluteDataPath = oldDataPath.map(path -> ModelHelper.getDisableConditionAbsolutePath(condition, path));
194        ModelItem conditionModelItem = getModelItem(definition.getModel(), conditionAbsoluteDataPath, contextualParameters);
195        if (!(conditionModelItem instanceof ElementDefinition conditionDefinition))
196        {
197            throw new BadItemTypeException("Unable to evaluate disable condition '" + conditionAbsoluteDataPath + "'. This condition referenced does not reference an element");
198        }
199        
200        Object conditionValue = containsValue(definition.getModel(), conditionAbsoluteDataPath, values, contextualParameters)
201                ? getValueFromMap(definition.getModel(), conditionAbsoluteDataPath, values, contextualParameters)
202                : object.isPresent()
203                        ? getStoredValue(conditionAbsoluteDataPath, oldConditionAbsoluteDataPath, object.get(), contextualParameters)
204                        : null;
205        
206        return new DefinitionAndValue(conditionDefinition, conditionValue);
207    }
208    
209    /**
210     * Retrieves the model item
211     * @param modelItemAccessor the relative accessor to the given data path
212     * @param dataPath the data path of the model item to retrieve
213     * @return the model item
214     * @param contextualParameters the contextual parameters
215     * @throws UndefinedItemPathException If no item is found for the given path
216     */
217    protected ModelItem getModelItem(ModelItemAccessor modelItemAccessor, String dataPath, Map<String, Object> contextualParameters) throws UndefinedItemPathException
218    {
219        return ModelHelper.getModelItem(dataPath, List.of(modelItemAccessor));
220    }
221    
222    /**
223     * Check if the values {@link Map} contains a value for the condition
224     * @param modelItemAccessor the relative accessor to the given condition path
225     * @param conditionDataPath the absolute data path of the condition
226     * @param values the values {@link Map}
227     * @param contextualParameters the contextual parameters
228     * @return <code>true</code> if there is a value (even empty) for the condition in the values {@link Map}, <code>false</code> otherwise
229     */
230    protected abstract boolean containsValue(ModelItemAccessor modelItemAccessor, String conditionDataPath, Map<String, Object> values, Map<String, Object> contextualParameters);
231    
232    /**
233     * Retrieves the condition value from the values {@link Map}
234     * @param modelItemAccessor the relative accessor to the given condition path
235     * @param conditionDataPath the absolute data path of the condition
236     * @param values the values {@link Map}
237     * @param contextualParameters the contextual parameters
238     * @return the condition value found in the values {@link Map}
239     */
240    protected abstract Object getValueFromMap(ModelItemAccessor modelItemAccessor, String conditionDataPath, Map<String, Object> values, Map<String, Object> contextualParameters);
241    
242    /**
243     * Retrieves the condition value from data stored in the given object
244     * @param conditionDataPath the absolute data path of the condition
245     * @param oldConditionDataPath the absolute old data path of the condition
246     * @param object the object holding the data to evaluate and the condition value
247     * @param contextualParameters the contextual parameters
248     * @return the condition value from data stored in the object
249     */
250    protected abstract Object getStoredValue(String conditionDataPath, Optional<String> oldConditionDataPath, T object, Map<String, Object> contextualParameters);
251    
252    /**
253     * Evaluate the given condition value against the given value
254     * @param conditionValue the condition value
255     * @param conditionOperator the condition operator
256     * @param value the value to check
257     * @return <code>true</code> if the condition is <code>true</code>, <code>false</code> otherwise
258     */
259    protected boolean evaluateDisableConditionValue(Object conditionValue, DisableCondition.OPERATOR conditionOperator, Object value)
260    {
261        if ((value == null || value instanceof Comparable) && (conditionValue == null || conditionValue instanceof Comparable))
262        {
263            @SuppressWarnings("unchecked")
264            Comparable<Object> comparableParameterValue = (Comparable<Object>) _emptyStringToNull(value);
265            @SuppressWarnings("unchecked")
266            Comparable<Object> comparableCompareValue = (Comparable<Object>) _emptyStringToNull(conditionValue);
267            
268            int comparison;
269            if (comparableParameterValue != null && comparableCompareValue != null)
270            {
271                comparison = comparableParameterValue.compareTo(comparableCompareValue);
272            }
273            else if (comparableCompareValue != null)
274            {
275                comparison = -1; // null comparableParameterValue is considered less than non-null comparableCompareValue
276            }
277            else if (comparableParameterValue != null)
278            {
279                comparison = 1; // non-null comparableParameterValue is considered greater than null comparableCompareValue
280            }
281            else
282            {
283                comparison = 0; // both are null
284            }
285            
286            switch (conditionOperator)
287            {
288                case NEQ:
289                    return comparison != 0;
290                case GEQ:
291                    return comparison >= 0;
292                case GT:
293                    return comparison > 0;
294                case LT:
295                    return comparison < 0;
296                case LEQ:
297                    return comparison <= 0;
298                case EQ:
299                default:
300                    return comparison == 0;
301            }
302        }
303        else
304        {
305            throw new IllegalStateException("Values '" + value + "' and '" + conditionValue + "' are not comparable");
306        }
307    }
308    
309    /**
310     * Convert empty string to null object, or retrieve the given value
311     * @param value the value
312     * @return null if the given value is an empty string, the value itself otherwise
313     */
314    protected static Object _emptyStringToNull(Object value)
315    {
316        if (StringUtils.EMPTY.equals(value))
317        {
318            return null;
319        }
320        else
321        {
322            return  value;
323        }
324    }
325    
326    /**
327     * Definition and value corresponding to a {@link DisableCondition}
328     * @param definition {@link ElementDefinition} corresponding to the value
329     * @param value the value
330     */
331    protected record DefinitionAndValue(ElementDefinition definition,  Object value) { /* empty */ }
332}