APIServiceFacadeImpl.java

/*
 * Copyright 2026 Global Crop Diversity Trust
 * Licensed under the Apache License, Version 2.0
 * See LICENSE file in project root folder or http://www.apache.org/licenses/LICENSE-2.0
 */

package org.gringlobal.api.v2.facade.impl;

import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;

import org.genesys.blocks.model.EmptyModel;
import org.genesys.blocks.model.filters.EmptyModelFilter;
import org.genesys.blocks.util.TransactionHelper;

import org.gringlobal.api.FilteredPage;
import org.gringlobal.api.MultiOp;
import org.gringlobal.api.NestedEntityFullUpdate;
import org.gringlobal.api.exception.NotFoundElement;
import org.gringlobal.api.v2.facade.APIServiceFacade;
import org.gringlobal.api.v2.mapper.MapstructMapper;
import org.gringlobal.model.AuditedModel;
import org.gringlobal.model.DateVersionEntityId;
import org.gringlobal.service.CRUDService2;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;

@Slf4j
public abstract class APIServiceFacadeImpl<S extends CRUDService2<T>, DTO, T extends EmptyModel> implements APIServiceFacade<DTO, T> {

	protected static final int ENTITY_GENERIC_INDEX = 2;

	private Type entityType = ((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[ENTITY_GENERIC_INDEX];

	@Autowired
	protected S service;

	@Autowired
	protected MapstructMapper mapper;

	@Autowired
	protected ObjectMapper objectMapper;

	@Autowired
	protected TransactionHelper transactionHelper;

	// Every instance gets their own DTO updater
	private final DtoUpdater dtoUpdater = new DtoUpdater();

	/** Use {@code mapper.map(source)} in implemented methods */
	protected abstract @NotNull @Valid T convert(DTO source);

	/** Use {@code mapper.map(source)} in implemented methods */
	protected abstract @NotNull @Valid DTO convert(T source);

	protected APIServiceFacadeImpl() {
		if (entityType instanceof Class) {
			log.debug("{} is for {}", this.getClass(), entityType);
		} else {
			log.debug("{} is for type {} {}", this.getClass(), entityType.getClass(), entityType);
		}
	}

	@Override
	public String getServiceEntityName() {
		return service.getServiceEntityName();
	}

	@Override
	public T create(DTO source) {
		return service.createFast(convert(source));
	}

	@Override
	public MultiOp<T> create(List<DTO> inserts) {
		return MultiOp.multiOp(mapper.map(inserts, this::convert), service::createFast);
	}

	@Override
	public DTO get(long id) {
		return convert(service.get(id));
	}

	@Override
	public DTO get(T source) {
		return convert(service.get(source));
	}

	@Override
	public DTO get(DateVersionEntityId.EntityIdAndModifiedDate id) {
		return convert(service.get(id));
	}

	@Override
	public DTO get(long id, Instant modifiedDate) {
		return convert(service.get(id, modifiedDate));
	}

	@Override
	public List<DTO> get(List<T> source) {
		return mapper.map(service.get(source), this::convert);
	}

	@Override
	public Page<DTO> list(Pageable page) {
		return mapper.map(service.list(page), this::convert);
	}

	@Override
	public T update(DTO updated) {
		return service.updateFast(convert(updated), service.get(convert(updated)));
	}

	@Override
	public MultiOp<T> update(List<DTO> updates) {
		// Uses per-row permission checks
		return MultiOp.multiOp(mapper.map(updates, this::convert), service::updateFast);
	}

	@Override
	public DTO remove(DTO entity) {
		service.remove(convert(entity));
		return entity;
	}

	@Override
	public MultiOp<DTO> remove(List<DTO> deletes) {
		// Uses per-row permission checks
		return MultiOp.multiOp(deletes, this::remove);
	}

	@Override
	public MultiOp<T> upsertMany(List<DTO> updates) {
		return MultiOp.upsert(mapper.map(updates, this::convert), service::createFast, service::updateFast);
	}

	@Override
	public final Page<DTO> map(Page<T> source) {
		return new PageImpl<DTO>(get(source.getContent()), source.getPageable(), source.getTotalElements());
	}

	@Override
	public final <F extends EmptyModelFilter<F, T, ?>> FilteredPage<DTO, F> map(FilteredPage<T, F> source) {
		return new FilteredPage<DTO, F>(source.filterCode, source.filter, map(source.page));
	}

	@Override
	public ResponseEntity<StreamingResponseBody> generateReport(String reportTemplate, Set<Long> entityIds, Locale locale,
		HttpServletResponse response) throws Exception {
		return service.generateReport(reportTemplate, entityIds, locale, response);
	}

	@Override
	public MultiOp<DTO> bulkUpdate(@NotNull JsonNode updates, @NotNull @NotEmpty List<DateVersionEntityId.EntityIdAndModifiedDate> entities) {
		if (updates.isNull() || updates.properties().isEmpty()) {
			return new MultiOp<>();
		}

		List<MultiOp.MultiOpError> errors = new LinkedList<>();
		List<T> success = new LinkedList<>();

		Lists.partition(entities, 100).forEach(entitiesPart -> {
			try {
				Map<DateVersionEntityId.EntityIdAndModifiedDate, DTO> toUpdate = new LinkedHashMap<>();
				Map<Long, DTO> idDtoMap = new LinkedHashMap<>();
				List<T> loadedEntities = new LinkedList<>();
				transactionHelper.executeInTransaction(true, () -> {
					loadedEntities.addAll(service.getByIds(
						entitiesPart.stream().map(DateVersionEntityId.EntityIdAndModifiedDate::getId).collect(Collectors.toList()))
					);
					idDtoMap.putAll(loadedEntities.stream().collect(Collectors.toMap(EmptyModel::getId, this::convert)));
					return true;
				});
				for (DateVersionEntityId.EntityIdAndModifiedDate entityIdModifiedDate : entitiesPart) {
					var loadedEntity = loadedEntities.stream().filter(loaded -> loaded.getId().equals(entityIdModifiedDate.id)).findFirst().orElse(null);
					if (loadedEntity == null) {
						errors.add(new MultiOp.MultiOpError(entities.indexOf(entityIdModifiedDate), new NotFoundElement("Entity not found: " + entityIdModifiedDate.id)));
					} else if (((AuditedModel) loadedEntity).getModifiedDate() != null
						&& !((AuditedModel) loadedEntity).getModifiedDate().equals(entityIdModifiedDate.modifiedDate)) {

						log.warn("modifiedDate mismatch got={} want={}", ((AuditedModel) loadedEntity).getModifiedDate(), entityIdModifiedDate.modifiedDate);
						errors.add(new MultiOp.MultiOpError(
							entities.indexOf(entityIdModifiedDate),
							new ObjectOptimisticLockingFailureException(loadedEntity.getClass(), loadedEntity.getId())
						));
					} else {
						toUpdate.put(entityIdModifiedDate, idDtoMap.get(loadedEntity.getId()));
					}
				}
				for (Map.Entry<DateVersionEntityId.EntityIdAndModifiedDate, DTO> entry : toUpdate.entrySet()) {
					try {
						var targetDTO = entry.getValue();
						applyUpdates(targetDTO, updates, getServiceEntityName());
						var updated = service.updateFast(convert(targetDTO));
						success.add(updated);
					} catch (Throwable t) {
						errors.add(new MultiOp.MultiOpError(entities.indexOf(entry.getKey()), t));
					}
				}
			} catch (Throwable err) {
				log.error("Bulk update error: {}", err.getMessage(), err);
				throw err;
			}
		});

		return transactionHelper.executeInTransaction(true, () ->
			new MultiOp<>(service.get(success).stream().map(this::convert).collect(Collectors.toList()), errors)
		);
	}

	private void applyUpdates(Object target, JsonNode node, String path) throws Exception {
		for (Map.Entry<String, JsonNode> entry : node.properties()) {
			String fieldName = entry.getKey();
			JsonNode value = entry.getValue();

			if (fieldName.equals("_class")) {
				continue;
			}

			Field field = dtoUpdater.findField(target.getClass(), fieldName);
			String currentPath = path + "." + fieldName;

			if (value.isObject()) {
				Object nestedTarget = ReflectionUtils.getField(field, target);

				if (nestedTarget == null) {
					log.trace("{}.{} is null, must instantiate", target, currentPath);
					var nestedFieldType = field.getType();

					// Check if a nested field's type is a base class for subtypes
					var subTypesAnnotation = nestedFieldType.getAnnotation(JsonSubTypes.class);
					var nestedTypeIdentifier = value.get("_class");
					if (subTypesAnnotation != null && nestedTypeIdentifier != null) {
						var types = subTypesAnnotation.value();
						for (JsonSubTypes.Type type : types) {
							if (type.name().equals(nestedTypeIdentifier.asText())) {
								nestedFieldType = type.value();
								break;
							}
						}
					}
					nestedTarget = nestedFieldType.getDeclaredConstructor().newInstance();
					ReflectionUtils.setField(field, target, nestedTarget);
				}

				// If the NestedEntityFullUpdate annotation is present, apply all given fields to the nested entity
				// otherwise, try to set only the reference id
				var nestedEntityId = value.get("id");
				if (field.isAnnotationPresent(NestedEntityFullUpdate.class)) {
					applyUpdates(nestedTarget, value, currentPath);
				} else if (nestedEntityId != null) {
					Field nestedIdField = dtoUpdater.findField(nestedTarget.getClass(), "id");
					ReflectionUtils.setField(nestedIdField, nestedTarget, nestedEntityId.asLong());
				}
				// Ignore other types?
			} else if (value.isNull()) {
				if (field.getType().isPrimitive()) {
					throw new IllegalArgumentException("Cannot set primitive field " + currentPath + " to null");
				}
				ReflectionUtils.setField(field, target, null);
			} else {
				var targetValue = objectMapper.convertValue(value, objectMapper.constructType(field.getGenericType()));
				ReflectionUtils.setField(field, target, targetValue);
			}
		}
	}


	/**
	 * DtoUpdater uses caching
	 */
	private static class DtoUpdater {

		private final Map<Class<?>, Map<String, Field>> classFields = new LinkedHashMap<>();

		public Field findField(Class<?> targetClass, String fieldName) {
			var classFieldsMap = classFields.computeIfAbsent(targetClass, (k) -> {
				log.trace("First time visiting {}", k);
				return new LinkedHashMap<>();
			});

			var field = classFieldsMap.get(fieldName);
			if (field != null) {
				log.trace("Re-visiting {}.{} of type {}", targetClass, fieldName);
				return field;
			} else if (classFieldsMap.containsKey(fieldName)) {
				log.trace("Field {} was not found in earlier run, is null", fieldName);
				throw new IllegalArgumentException("Field " + fieldName + " not found in " + targetClass.getName());
			}

			field = ReflectionUtils.findField(targetClass, fieldName);
			if (field == null) {
				log.debug("Field not found by simple name: {}.{}", targetClass, fieldName);
				ReflectionUtils.doWithFields(targetClass, (f) -> {
					var jsonName = f.getAnnotation(JsonProperty.class);
					if (jsonName != null && jsonName.value() != null && Objects.equals(jsonName.value(), fieldName)) {
						log.debug("Field {}.{} found by @JsonProperty({})", targetClass, f.getName(), fieldName);
						ReflectionUtils.makeAccessible(f);
						classFieldsMap.put(fieldName, f);
					}
				});
				field = classFieldsMap.get(fieldName);
				if (field == null) {
					classFieldsMap.put(fieldName, null);
					throw new IllegalArgumentException("Field " + fieldName + " not found in " + targetClass.getName());
				} else {
					return field;
				}
			} else {
				log.trace("First time visiting {}.{} of type {}", targetClass, fieldName, field.getType());
				ReflectionUtils.makeAccessible(field);
				classFieldsMap.put(fieldName, field);
				return field;
			}

		}
	}
}