ValidatableProperty.java

package net.hostsharing.hsadminng.hs.validation;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.experimental.Accessors;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
import net.hostsharing.hsadminng.mapper.Array;
import org.apache.commons.lang3.function.TriFunction;

import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;

import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static org.apache.commons.lang3.ObjectUtils.isArray;

@Getter
@RequiredArgsConstructor
public abstract class ValidatableProperty<P extends ValidatableProperty<?, ?>, T> {

    protected static final String[] KEY_ORDER_HEAD = Array.of("propertyName");
    protected static final String[] KEY_ORDER_TAIL = Array.of("required", "requiresAtLeastOneOf", "requiresAtMaxOneOf", "defaultValue", "readOnly", "writeOnce","writeOnly", "computed", "isTotalsValidator", "thresholdPercentage");
    protected static final String[] KEY_ORDER = Array.join(KEY_ORDER_HEAD, KEY_ORDER_TAIL);

    final Class<T> type;
    final String propertyName;

    @JsonIgnore
    private final String[] keyOrder;

    private Boolean required;
    private Set<String> requiresAtLeastOneOf;
    private Set<String> requiresAtMaxOneOf;
    private T defaultValue;

    protected enum ComputeMode {
        IN_INIT,
        IN_PREP,
        IN_REVAMP
    }

    @JsonIgnore
    private BiFunction<EntityManager, PropertiesProvider, T> computedBy;

    @Accessors(makeFinal = true, chain = true, fluent = false)
    private ComputeMode computed; // name 'computed' instead of 'computeMode' for better readability in property description

    @Accessors(makeFinal = true, chain = true, fluent = false)
    private boolean readOnly;

    @Accessors(makeFinal = true, chain = true, fluent = false)
    private boolean writeOnly;

    @Accessors(makeFinal = true, chain = true, fluent = false)
    private boolean writeOnce;

    private Function<ValidatableProperty<?, ?>[], T[]> deferredInit;
    private boolean isTotalsValidator = false;

    @JsonIgnore
    private List<Function<HsBookingItem, List<String>>> asTotalLimitValidators; // TODO.impl: move to BookingItemIntegerProperty

    private Integer thresholdPercentage; // TODO.impl: move to IntegerProperty

    public final P self() {
        //noinspection unchecked
        return (P) this;
    }

    public String unit() {
        return null;
    }

    protected void setDeferredInit(final Function<ValidatableProperty<?, ?>[], T[]> function) {
        this.deferredInit = function;
    }

    public boolean hasDeferredInit() {
        return deferredInit != null;
    }

    public T[] doDeferredInit(final ValidatableProperty<?, ?>[] allProperties) {
        return deferredInit.apply(allProperties);
    }

    public P writeOnly() {
        this.writeOnly = true;
        return self();
    }

    public P writeOnce() {
        this.writeOnce = true;
        return self();
    }

    public P readOnly() {
        this.readOnly = true;
        return self();
    }

    public P required() {
        required = TRUE;
        return self();
    }

    public P optional() {
        required = FALSE;
        return self();
    }

    public P requiresAtLeastOneOf(final String... propNames) {
        requiresAtLeastOneOf = new LinkedHashSet<>(List.of(propNames));
        return self();
    }

    public P requiresAtMaxOneOf(final String... propNames) {
        requiresAtMaxOneOf = new LinkedHashSet<>(List.of(propNames));
        return self();
    }

    public P withDefault(final T value) {
        defaultValue = value;
        required = FALSE;
        return self();
    }

    public void deferredInit(final ValidatableProperty<?, ?>[] allProperties) {
    }

    public P asTotalLimit() {
        isTotalsValidator = true;
        return self();
    }

    public P asTotalLimitFor(final String propertyName, final String propertyValue) {
        if (asTotalLimitValidators == null) {
            asTotalLimitValidators = new ArrayList<>();
        }
        final TriFunction<HsBookingItem, IntegerProperty<?>, Integer, List<String>> validator =
                (final HsBookingItem entity, final IntegerProperty<?> prop, final Integer factor) -> {

            final var total = entity.getSubBookingItems().stream()
                    .map(server -> server.getResources().get(propertyName))
                    .filter(propertyValue::equals)
                    .count();

            final long limitingValue = ofNullable(prop.getValue(entity.getResources())).orElse(0);
            if (total > factor*limitingValue) {
                return List.of(
                    prop.propertyName() + " maximum total is " + (factor*limitingValue) + ", but actual total for " + propertyName + "=" + propertyValue + " is " + total
                );
            }
            return emptyList();
        };
        asTotalLimitValidators.add((final HsBookingItem entity) -> validator.apply(entity, (IntegerProperty)this,  1));
        return self();
    }

    public String propertyName() {
        return propertyName;
    }

    public boolean isTotalsValidator() {
        return isTotalsValidator || asTotalLimitValidators != null;
    }

    public Integer thresholdPercentage() {
        return thresholdPercentage;
    }

    public ValidatableProperty<P, T> eachComprising(final int factor, final TriFunction<HsBookingItem, IntegerProperty<?>, Integer, List<String>> validator) {
        if (asTotalLimitValidators == null) {
            asTotalLimitValidators = new ArrayList<>();
        }
        asTotalLimitValidators.add((final HsBookingItem entity) -> validator.apply(entity, (IntegerProperty<?>)this,  factor));
        return this;
    }

    public P withThreshold(final Integer percentage) {
        this.thresholdPercentage = percentage;
        return self();
    }

    public final List<String> validate(final PropertiesProvider propsProvider) {
        final var result = new ArrayList<String>();
        final var props = propsProvider.directProps();
        final var propValue = props.get(propertyName);

        if (propValue == null) {
            if (required == TRUE) {
                result.add(propertyName + "' is required but missing");
            }
            if (isWriteOnce() && propsProvider.isLoaded() && propsProvider.isPatched(propertyName) ) {
                result.add(propertyName + "' is write-once but got removed");
            }
            validateRequiresAtLeastOneOf(result, propsProvider);
        }
        if (propValue != null){
            validateRequiresAtMaxOneOf(result, propsProvider);

            if ( type.isInstance(propValue)) {
                //noinspection unchecked
                validate(result, (T) propValue, propsProvider);
            } else {
                result.add(propertyName + "' is expected to be of type " + type.getSimpleName() +
                        " but is of type " + propValue.getClass().getSimpleName());
            }
        }
        return result;
    }

    private void validateRequiresAtLeastOneOf(final ArrayList<String> result, final PropertiesProvider propsProvider) {
        if (requiresAtLeastOneOf != null ) {
            final var allPropNames = propsProvider.directProps().keySet();
            final var entriesWithValue = allPropNames.stream()
                    .filter(name -> requiresAtLeastOneOf.contains(name))
                    .count();
            if (entriesWithValue == 0) {
                result.add(propertyName + "' is required once in group " + requiresAtLeastOneOf + " but missing");
            }
        }
    }

    private void validateRequiresAtMaxOneOf(final ArrayList<String> result, final PropertiesProvider propsProvider) {
        if (requiresAtMaxOneOf != null) {
            final var allPropNames = propsProvider.directProps().keySet();
            final var entriesWithValue = allPropNames.stream()
                    .filter(name -> requiresAtMaxOneOf.contains(name))
                    .count();
            if (entriesWithValue > 1) {
                result.add(propertyName + "' is required at max once in group " + requiresAtMaxOneOf
                        + " but multiple properties are set");
            }
        }
    }

    protected void validate(final List<String> result, final T propValue, final PropertiesProvider propProvider) {
        if (isReadOnly() && propValue != null) {
            result.add(propertyName + "' is readonly but given as " + display(propValue));
        }
        if (isWriteOnce() && propProvider.isLoaded() && propValue != null && propProvider.isPatched(propertyName) ) {
            result.add(propertyName + "' is write-once but given as " + display(propValue));
        }
    }

    public void verifyConsistency(final Map.Entry<? extends Enum<?>, ?> typeDef) {
        if (isSpecPotentiallyComplete()) {
            throw new IllegalStateException(typeDef.getKey() + "[" + propertyName + "] not fully initialized, please call either .readOnly(), .required(), .optional(), .withDefault(...), .requiresAtLeastOneOf(...) or .requiresAtMaxOneOf(...)" );
        }
    }

    private boolean isSpecPotentiallyComplete() {
        return required == null && requiresAtLeastOneOf == null && requiresAtMaxOneOf == null && !readOnly && !writeOnly
                && defaultValue == null && computedBy == null;
    }

    @SuppressWarnings("unchecked")
    public T getValue(final Map<String, Object> propValues) {
        return (T) Optional.ofNullable(propValues.get(propertyName)).orElse(defaultValue);
    }

    protected String display(final T propValue) {
        return propValue == null ? null : propValue.toString();
    }

    protected abstract String simpleTypeName();

    public Map<String, Object> toOrderedMap() {
            Map<String, Object> sortedMap = new LinkedHashMap<>();
            sortedMap.put("type", simpleTypeName());

            // Add entries according to the given order
            for (String key : keyOrder) {
                final Optional<Object> propValue = getPropertyValue(key);
                propValue.filter(ValidatableProperty::isToBeRendered).ifPresent(o -> sortedMap.put(key, o));
            }

            return sortedMap;
    }

    private static boolean isToBeRendered(final Object v) {
        return !(v instanceof Boolean b) || b;
    }

    @SneakyThrows
    private Optional<Object> getPropertyValue(final String key) {
        return getPropertyValue(getClass(), key);
    }

    @SneakyThrows
    private Optional<Object> getPropertyValue(final Class<?> clazz, final String key) {
        try {
            final var field = clazz.getDeclaredField(key);
            field.setAccessible(true);
            return Optional.ofNullable(arrayToList(field.get(this)));
        } catch (final NoSuchFieldException exc) {
            if (clazz.getSuperclass() != null) {
                return getPropertyValue(clazz.getSuperclass(), key);
            }
            throw exc;
        }
    }

    private Object arrayToList(final Object value) {
        if (isArray(value)) {
            return Arrays.stream((Object[])value).map(Object::toString).toList();
        }
        return value;
    }

    public List<String> validateTotals(final HsBookingItem bookingItem) {
        if (asTotalLimitValidators==null) {
            return emptyList();
        }
        return asTotalLimitValidators.stream()
                .map(v -> v.apply(bookingItem))
                .filter(Objects::nonNull)
                .flatMap(Collection::stream)
                .toList();
    }

    public P initializedBy(final BiFunction<EntityManager, PropertiesProvider, T> compute) {
        return computedBy(ComputeMode.IN_INIT, compute);
    }

    public P renderedBy(final BiFunction<EntityManager, PropertiesProvider, T> compute) {
        return computedBy(ComputeMode.IN_REVAMP, compute);
    }

    protected P computedBy(final ComputeMode computeMode, final BiFunction<EntityManager, PropertiesProvider, T> compute) {
        this.computedBy = compute;
        this.computed = computeMode;
        return self();
    }

    public boolean isComputed(final ComputeMode computeMode) {
        return computed == computeMode;
    }

    public <E extends PropertiesProvider> T compute(final EntityManager em, final E entity) {
        return computedBy.apply(em, entity);
    }

    @Override
    public String toString() {
        return toOrderedMap().toString();
    }
}