using System.Globalization;
using Prefab.Catalog.Domain.Entities;
namespace Prefab.Catalog.Domain.Services;
///
/// Represents a caller selection for an option definition.
///
public sealed record OptionSelection(Guid OptionDefinitionId, Guid? OptionValueId, decimal? NumericValue);
///
/// Result produced after applying rule evaluation to a set of selections.
///
public sealed class RuleEvaluationResult
{
public RuleEvaluationResult(IReadOnlyCollection selections, IReadOnlyCollection errors)
{
Selections = selections;
Errors = errors;
}
public IReadOnlyCollection Selections { get; }
public IReadOnlyCollection Errors { get; }
public bool IsValid => Errors.Count == 0;
}
///
/// Applies OptionRuleSets to a model to determine visibility, enablement, and required selections.
///
public sealed class RuleEvaluator
{
public RuleEvaluationResult Evaluate(Product model, IReadOnlyCollection selections)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentNullException.ThrowIfNull(selections);
var optionById = model.Options
.Where(o => o.DeletedOn is null)
.ToDictionary(o => o.Id);
var valueById = model.Options
.Where(o => o.DeletedOn is null)
.SelectMany(o => o.Values.Where(v => v.DeletedOn is null))
.ToDictionary(v => v.Id);
var selectionMap = selections.ToDictionary(s => s.OptionDefinitionId);
var visibleRuleSets = model.RuleSets
.Where(r => r.DeletedOn is null)
.GroupBy(r => (r.Effect, r.TargetKind, r.TargetId))
.ToList();
var hiddenDefinitions = new HashSet();
var hiddenValues = new HashSet();
var disabledDefinitions = new HashSet();
var disabledValues = new HashSet();
var requiredDefinitions = new HashSet();
var requiredValuesByDefinition = new Dictionary>();
var errors = new List();
EvaluateShowRules(visibleRuleSets, selectionMap, optionById, valueById, hiddenDefinitions, hiddenValues);
var prunedSelections = PruneHiddenSelections(selections, hiddenDefinitions, hiddenValues);
EvaluateEnableRules(visibleRuleSets, selectionMap, optionById, valueById, hiddenDefinitions, disabledDefinitions, disabledValues);
var enabledSelections = RejectDisabledSelections(prunedSelections, disabledDefinitions, disabledValues, optionById, valueById, errors);
EvaluateRequireRules(
visibleRuleSets,
selectionMap,
optionById,
valueById,
hiddenDefinitions,
requiredDefinitions,
requiredValuesByDefinition);
EnsureRequirementsMet(enabledSelections, optionById, requiredDefinitions, requiredValuesByDefinition, errors);
return new RuleEvaluationResult(enabledSelections, errors);
}
private static void EvaluateShowRules(
IEnumerable> groupedRuleSets,
IReadOnlyDictionary selectionMap,
IReadOnlyDictionary optionById,
IReadOnlyDictionary valueById,
ISet hiddenDefinitions,
ISet hiddenValues)
{
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Show))
{
var shouldShow = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
if (shouldShow)
{
continue;
}
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
{
hiddenDefinitions.Add(group.Key.TargetId);
}
else
{
hiddenValues.Add(group.Key.TargetId);
}
}
}
private static IReadOnlyCollection PruneHiddenSelections(
IReadOnlyCollection selections,
ISet hiddenDefinitions,
ISet hiddenValues)
{
var result = new List(selections.Count);
foreach (var selection in selections)
{
if (hiddenDefinitions.Contains(selection.OptionDefinitionId))
{
continue;
}
if (selection.OptionValueId.HasValue && hiddenValues.Contains(selection.OptionValueId.Value))
{
continue;
}
result.Add(selection);
}
return result;
}
private static void EvaluateEnableRules(
IEnumerable> groupedRuleSets,
IReadOnlyDictionary selectionMap,
IReadOnlyDictionary optionById,
IReadOnlyDictionary valueById,
ISet hiddenDefinitions,
ISet disabledDefinitions,
ISet disabledValues)
{
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Enable))
{
var targetId = group.Key.TargetId;
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition && hiddenDefinitions.Contains(targetId))
{
continue;
}
var shouldEnable = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
if (shouldEnable)
{
continue;
}
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
{
disabledDefinitions.Add(group.Key.TargetId);
}
else
{
disabledValues.Add(group.Key.TargetId);
}
}
}
private static IReadOnlyCollection RejectDisabledSelections(
IReadOnlyCollection selections,
ISet disabledDefinitions,
ISet disabledValues,
IReadOnlyDictionary optionById,
IReadOnlyDictionary valueById,
ICollection errors)
{
var result = new List(selections.Count);
foreach (var selection in selections)
{
if (disabledDefinitions.Contains(selection.OptionDefinitionId))
{
var option = optionById.GetValueOrDefault(selection.OptionDefinitionId);
var optionName = option?.Name ?? selection.OptionDefinitionId.ToString();
errors.Add($"Option '{optionName}' is disabled for the current configuration.");
continue;
}
if (selection.OptionValueId.HasValue && disabledValues.Contains(selection.OptionValueId.Value))
{
var value = valueById.GetValueOrDefault(selection.OptionValueId.Value);
var option = optionById.GetValueOrDefault(selection.OptionDefinitionId);
var optionName = option?.Name ?? selection.OptionDefinitionId.ToString();
var valueName = value?.Label ?? selection.OptionValueId.Value.ToString();
errors.Add($"Option value '{valueName}' for '{optionName}' is disabled for the current configuration.");
continue;
}
result.Add(selection);
}
return result;
}
private static void EvaluateRequireRules(
IEnumerable> groupedRuleSets,
IReadOnlyDictionary selectionMap,
IReadOnlyDictionary optionById,
IReadOnlyDictionary valueById,
ISet hiddenDefinitions,
ISet requiredDefinitions,
IDictionary> requiredValuesByDefinition)
{
foreach (var group in groupedRuleSets.Where(g => g.Key.Effect == RuleEffect.Require))
{
var targetId = group.Key.TargetId;
var isSatisfied = group.Any(ruleSet => EvaluateRuleSet(ruleSet, selectionMap, optionById, valueById));
if (!isSatisfied)
{
continue;
}
if (group.Key.TargetKind == OptionRuleTargetKind.OptionDefinition)
{
if (!hiddenDefinitions.Contains(targetId))
{
requiredDefinitions.Add(targetId);
}
}
else
{
var optionValue = valueById.GetValueOrDefault(targetId);
if (optionValue is null)
{
continue;
}
if (hiddenDefinitions.Contains(optionValue.OptionDefinitionId))
{
continue;
}
if (!requiredValuesByDefinition.TryGetValue(optionValue.OptionDefinitionId, out var set))
{
set = new HashSet();
requiredValuesByDefinition[optionValue.OptionDefinitionId] = set;
}
set.Add(targetId);
}
}
}
private static void EnsureRequirementsMet(
IReadOnlyCollection selections,
IReadOnlyDictionary optionById,
ISet requiredDefinitions,
IDictionary> requiredValuesByDefinition,
ICollection errors)
{
var selectionMap = selections.ToDictionary(s => s.OptionDefinitionId);
foreach (var requiredDefinitionId in requiredDefinitions)
{
if (!selectionMap.TryGetValue(requiredDefinitionId, out var selection) || selection.OptionValueId is null && selection.NumericValue is null)
{
var option = optionById.GetValueOrDefault(requiredDefinitionId);
var optionName = option?.Name ?? requiredDefinitionId.ToString();
errors.Add($"Option '{optionName}' is required.");
}
}
foreach (var kvp in requiredValuesByDefinition)
{
if (!selectionMap.TryGetValue(kvp.Key, out var selection) || selection.OptionValueId is null)
{
var option = optionById.GetValueOrDefault(kvp.Key);
var optionName = option?.Name ?? kvp.Key.ToString();
errors.Add($"Option '{optionName}' requires a specific value.");
continue;
}
if (!kvp.Value.Contains(selection.OptionValueId.Value))
{
var option = optionById.GetValueOrDefault(kvp.Key);
var optionName = option?.Name ?? kvp.Key.ToString();
errors.Add($"Option '{optionName}' requires a different value for the current configuration.");
}
}
}
private static bool EvaluateRuleSet(
OptionRuleSet ruleSet,
IReadOnlyDictionary selectionMap,
IReadOnlyDictionary optionById,
IReadOnlyDictionary valueById)
{
var conditions = ruleSet.Conditions.Where(c => c.DeletedOn is null).ToList();
if (conditions.Count == 0)
{
return ruleSet.Mode switch
{
RuleMode.All => true,
RuleMode.Any => false,
_ => false
};
}
var evaluations = conditions
.Select(condition => EvaluateCondition(condition, selectionMap, optionById, valueById))
.ToList();
return ruleSet.Mode switch
{
RuleMode.All => evaluations.All(result => result),
RuleMode.Any => evaluations.Any(result => result),
_ => false
};
}
private static bool EvaluateCondition(
OptionRuleCondition condition,
IReadOnlyDictionary selectionMap,
IReadOnlyDictionary optionById,
IReadOnlyDictionary valueById)
{
if (!optionById.TryGetValue(condition.LeftOptionDefinitionId, out var leftOption))
{
return false;
}
selectionMap.TryGetValue(leftOption.Id, out var selection);
return leftOption.DataType switch
{
OptionDataType.Choice => EvaluateChoiceCondition(condition, leftOption, selection, valueById),
OptionDataType.Number => EvaluateNumericCondition(condition, selection),
_ => false
};
}
private static bool EvaluateChoiceCondition(
OptionRuleCondition condition,
OptionDefinition option,
OptionSelection? selection,
IReadOnlyDictionary valueById)
{
var selectedValueId = selection?.OptionValueId;
var selectedValue = selectedValueId.HasValue ? valueById.GetValueOrDefault(selectedValueId.Value) : null;
switch (condition.Operator)
{
case RuleOperator.Equal:
if (condition.RightOptionValueId.HasValue)
{
return selectedValueId.HasValue && selectedValueId.Value == condition.RightOptionValueId.Value;
}
return MatchesList(condition.RightList, selectedValue, selectedValueId);
case RuleOperator.NotEqual:
if (condition.RightOptionValueId.HasValue)
{
return !selectedValueId.HasValue || selectedValueId.Value != condition.RightOptionValueId.Value;
}
return !MatchesList(condition.RightList, selectedValue, selectedValueId);
case RuleOperator.InList:
return MatchesList(condition.RightList, selectedValue, selectedValueId);
case RuleOperator.NotInList:
return !MatchesList(condition.RightList, selectedValue, selectedValueId);
default:
return false;
}
}
private static bool MatchesList(string? csv, OptionValue? selectedValue, Guid? selectedValueId)
{
if (selectedValueId is null || string.IsNullOrWhiteSpace(csv))
{
return false;
}
var tokens = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var token in tokens)
{
if (Guid.TryParse(token, out var guidToken))
{
if (guidToken == selectedValueId.Value)
{
return true;
}
continue;
}
if (selectedValue is not null && string.Equals(selectedValue.Code, token, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool EvaluateNumericCondition(OptionRuleCondition condition, OptionSelection? selection)
{
var selected = selection?.NumericValue;
if (selected is null)
{
return false;
}
return condition.Operator switch
{
RuleOperator.Equal => condition.RightNumber.HasValue && selected.Value == condition.RightNumber.Value,
RuleOperator.NotEqual => !condition.RightNumber.HasValue || selected.Value != condition.RightNumber.Value,
RuleOperator.InList => MatchesNumericList(condition.RightList, selected.Value),
RuleOperator.NotInList => !MatchesNumericList(condition.RightList, selected.Value),
RuleOperator.GreaterThan => condition.RightNumber.HasValue && selected.Value > condition.RightNumber.Value,
RuleOperator.GreaterThanOrEqual => condition.RightNumber.HasValue && selected.Value >= condition.RightNumber.Value,
RuleOperator.LessThan => condition.RightNumber.HasValue && selected.Value < condition.RightNumber.Value,
RuleOperator.LessThanOrEqual => condition.RightNumber.HasValue && selected.Value <= condition.RightNumber.Value,
RuleOperator.Between => condition.RightMin.HasValue && condition.RightMax.HasValue && selected.Value >= condition.RightMin.Value && selected.Value <= condition.RightMax.Value,
_ => false
};
}
private static bool MatchesNumericList(string? csv, decimal selected)
{
if (string.IsNullOrWhiteSpace(csv))
{
return false;
}
var tokens = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var token in tokens)
{
if (decimal.TryParse(token, NumberStyles.Number, CultureInfo.InvariantCulture, out var value))
{
if (value == selected)
{
return true;
}
}
}
return false;
}
}