SysTableComponent.java

/*
 * Copyright 2022 Global Crop Diversity Trust
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.gringlobal.compatibility.component;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

import javax.annotation.Resource;
import javax.persistence.Column;
import javax.persistence.DiscriminatorValue;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.Lob;
import javax.persistence.ManyToOne;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;

import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.model.AuditedVersionedModel;
import org.genesys.blocks.model.BasicModel;
import org.genesys.blocks.model.EmptyModel;
import org.gringlobal.compatibility.SysTableFieldInfo;
import org.gringlobal.compatibility.SysTableInfo;
import org.gringlobal.compatibility.service.impl.VirtualDataviewServiceImpl;
import org.gringlobal.compatibility.service.impl.VirtualLookupServiceImpl;
import org.gringlobal.custom.validation.javax.CodeValueField;
import org.gringlobal.model.AuditedModel;
import org.gringlobal.model.CooperatorOwnedModel;
import org.gringlobal.model.SysTable;
import org.gringlobal.model.SysTableField;
import org.hibernate.annotations.Formula;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DelegatingIntroductionInterceptor;
import org.springframework.aop.support.NameMatchMethodPointcutAdvisor;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;


@Component
@Slf4j
public class SysTableComponent {

	private static final String FALSE = "N";
	private static final String TRUE = "Y";
	private static final String SYS_TABLE_ID_PREFIX = "sys_table_id";
	private static final String SYS_TABLE_FIELD_ID_PREFIX = "sys_table_field_id";

	// Clustered cache of identifiers
	@Resource(name = "legacySystemPrimaryKeys")
	private Map<String, Long> legacySystemPrimaryKeys;

	// JVM-bound cache
	private HashMap<String, SysTable> sysTableCache = new HashMap<>();
	private HashMap<String, SysTableField> sysTableFieldCache = new HashMap<>();

	public SysTable getSysTable(Class<?> target) {
		return sysTableCache.computeIfAbsent(target.getName(), (key) -> {
			log.debug("SysTable not cached for key={} and needs to be generated", key);
			return generateSysTable(target);
		});
	}

	public SysTable generateSysTable(Class<?> target) {
		String tableName;
		Table table = target.getAnnotation(Table.class);
		if (table != null) {
			tableName = table.name();
		} else {
			// Handle JPA inheritance
			var discriminatorValue = target.getAnnotation(DiscriminatorValue.class);
			if (discriminatorValue != null) {
				table = target.getSuperclass().getAnnotation(Table.class);
				if (table != null) {
					tableName = table.name();
					log.warn("Using virtual table {} for {}", tableName, target.getSimpleName());
				} else {
					log.warn("No SysTable for {}, missing @Table on superclass {}", target, target.getSuperclass());
					return null;
				}
			} else {
				log.warn("No SysTable for {}, missing @DiscriminatorValue", target);
				return null;
			}
		}

		SysTable sysTable = new SysTable();

		sysTable.setId(getCachedId(SYS_TABLE_ID_PREFIX, tableName));
		sysTable.setTableName(tableName);
		sysTable.setIsEnabled("Y");
		sysTable.setIsReadonly(Modifier.isAbstract(target.getModifiers()) ? "Y" : "N");
		sysTable.setAuditsCreated("N");
		sysTable.setAuditsModified("N");
		sysTable.setAuditsOwned("N");
		if (CooperatorOwnedModel.class.isAssignableFrom(target)) {
			sysTable.setAuditsCreated("Y");
			sysTable.setAuditsModified("Y");
			sysTable.setAuditsOwned("Y");
		} else if (AuditedVersionedModel.class.isAssignableFrom(target)) {
			sysTable.setAuditsCreated("Y");
			sysTable.setAuditsModified("Y");
		}

		SysTableInfo sysTableInfo = target.getAnnotation(SysTableInfo.class);
		if (Objects.nonNull(sysTableInfo)) {
			String area = sysTableInfo.area();
			sysTable.setDatabaseAreaCode(DatabaseAreaCode.fromValue(area).value);
		}

		sysTable.setFields(getSysTableFields(target));
		// Because fields are statically cached, the next line changes their sysTable
		sysTable.getFields().forEach(field -> field.setTable(sysTable));

		return sysTable;
	}

	public List<SysTableField> getSysTableFields(Class<?> target) {
		List<SysTableField> fields = new ArrayList<>();

		ReflectionUtils.doWithFields(target, field -> {
			if (Modifier.isStatic(field.getModifiers())) {
				// Skip static fields
				return;
			}
			try {
				SysTableField sysTableField = getSysTableField(field, target);
				if (Objects.nonNull(sysTableField)) {
					log.trace("Adding field {}", field.getName());
					fields.add(sysTableField);
				}
			} catch (Exception e) {
				log.error("Exception in getting SysTableField from field", e);
			}
		});

		fields.sort((f1, f2) -> {
			var compareOrder = Optional.ofNullable(f1.getFieldOrdinal()).orElse(2)
				.compareTo(Optional.ofNullable(f2.getFieldOrdinal()).orElse(2));
			if (compareOrder == 0) {
				// required fields before other fields
				if (Objects.equals(f1.getIsNullable(), FALSE) && Objects.equals(f2.getIsNullable(), TRUE)) {
					return -1;
				} else if (Objects.equals(f2.getIsNullable(), FALSE)) {
					return 1;
				}
			}
			return compareOrder;
		});
		return fields;
	}

	public SysTableField getSysTableField(Field targetField, Class<?> targetClass) {
		var fieldCacheKey = String.format("%s#%s", targetClass.getName(), targetField.getName());
		return sysTableFieldCache.computeIfAbsent(fieldCacheKey, (key) -> {
			log.debug("SysTableField not cached for key={}", key);
			return generateSysTableField(targetField, targetClass);
		});
	}

	private SysTableField generateSysTableField(Field targetField, Class<?> targetClass) {

		log.trace("Generating SysTableField {}.{}", targetClass.getName(), targetField.getName());
		SysTableField field = new SysTableField();

		ReflectionUtils.makeAccessible(targetField);

		String declaredFieldName = getFieldGGName(targetField);
		if (StringUtils.isEmpty(declaredFieldName)) {
			return null;
		}

		if (Collection.class.isAssignableFrom(targetField.getType())) {
			return null; // all collections are ignored
		}

		if (Map.class.isAssignableFrom(targetField.getType())) {
			return null; // all maps are ignored
		}

//		if (Objects.nonNull(targetField.getAnnotation(OneToMany.class))) { // is a collection
//			return null;
//		}

		var fieldCacheKey = String.format("%s#%s", targetClass.getName(), targetField.getName()); // Full name as key: org.gringlobal.model.Accession#accession

		field.setPropertyName(targetField.getName());
		field.setId(getCachedId(SYS_TABLE_FIELD_ID_PREFIX, fieldCacheKey));
		field.setFieldName(declaredFieldName);
		field.setFieldType(FieldType.typeOfField(targetField).name());
		field.setIsForeignKey(isForeignKey(targetField) ? TRUE : FALSE);
		field.setIsPrimaryKey(isPrimaryKey(targetField) ? TRUE : FALSE);
		if (Objects.equals(field.getIsPrimaryKey(), TRUE)) {
			field.setFieldOrdinal(0); // set to 0
		}
		field.setMinLength(0);
		field.setMaxLength(getMaxLength(targetField));
		field.setIsNullable(isNullable(targetField) ? TRUE : FALSE);

		if (targetField.getName().startsWith("is")) {
			field.setDefaultValue("N");
		}

		field.setIsAutoincrement(isAutoincrement(targetField) ? TRUE : FALSE);
		field.setFieldPurpose(getFieldPurpose(targetField));

		var aColumn = targetField.getAnnotation(Column.class);
		var aCodeValueField = targetField.getAnnotation(CodeValueField.class);

		if (aCodeValueField == null) {
			// Check getter for AbstractAction implementations
			var targetFieldGetter = ReflectionUtils.findMethod(targetClass, "get" + StringUtils.capitalize(targetField.getName()));
			if (targetFieldGetter != null) {
				aCodeValueField = targetFieldGetter.getAnnotation(CodeValueField.class);
			}
		}

		if (Objects.nonNull(aCodeValueField)) {
			// Annotated with @CodeValueField
			field.setGuiHint(GuiHint.SMALL_SINGLE_SELECT_CONTROL.name());
			field.setGroupName(aCodeValueField.value());
		} else if (Objects.isNull(field.getGuiHint())) {
			field.setGuiHint(GuiHint.guiHintFromField(targetField).name());
		}

		if (Number.class.isAssignableFrom(targetField.getType())) {
			if (aColumn != null && aColumn.precision() > 0) {
				field.setNumericPrecision(aColumn.precision());
			} else {
				field.setNumericPrecision(18);
			}

			if (aColumn != null && aColumn.scale() > 0) {
				field.setNumericScale(aColumn.scale());
			} else if (Objects.equals(field.getFieldType(), FieldType.DECIMAL.name())) {
				field.setNumericScale(7);
			} else {
				field.setNumericScale(0);
			}
		}

		if (targetField.isAnnotationPresent(GeneratedValue.class)) {
			field.setIsReadonly(TRUE);
		} else if (targetField.isAnnotationPresent(Formula.class)) {
			field.setIsReadonly(TRUE);
		} else if (targetField.getName().equals("ownedBy")) { // No annotations
			field.setIsReadonly(TRUE);
			field.setFieldOrdinal(8);
		} else if (targetField.getName().equals("ownedDate")) { // No annotations
			field.setIsReadonly(TRUE);
			field.setFieldOrdinal(9);
		} else if (targetField.isAnnotationPresent(CreatedBy.class)) {
			field.setIsReadonly(TRUE);
			field.setFieldOrdinal(10);
		} else if (targetField.isAnnotationPresent(CreatedDate.class)) {
			field.setIsReadonly(TRUE);
			field.setFieldOrdinal(11);
		} else if (targetField.isAnnotationPresent(LastModifiedBy.class)) {
			field.setIsReadonly(TRUE);
			field.setFieldOrdinal(12);
		} else if (targetField.isAnnotationPresent(LastModifiedDate.class) || targetField.getName().equals("modifiedDate")) { // No modifiedDate is @Version in GGCE
			field.setIsReadonly(TRUE);
			field.setFieldOrdinal(13);
		} else if (targetField.isAnnotationPresent(SysTableFieldInfo.class)) {
			var stfi = targetField.getAnnotation(SysTableFieldInfo.class);
			field.setIsReadonly(stfi.readonly() ? TRUE : FALSE);
		} else if (targetField.isAnnotationPresent(JsonProperty.class)) {
			var jsonProperty = targetField.getAnnotation(JsonProperty.class);
			if (jsonProperty.access() == Access.READ_ONLY) {
				field.setIsReadonly(TRUE);
			}
		// TODO Include other relevant annotations
		} else {
			field.setIsReadonly(FALSE);
		}

		if (isForeignKey(targetField)) {

			// And all we need is the ID of the field
			// We are at risk of never-ending-loops here because of circular references.
			// That's why we have a Proxy (like a Hibernate Proxy) that will call
			// #getSysTableField() for target field when invoked.
			var foreignClass = targetField.getType();

			if (AuditedModel.class.isAssignableFrom(foreignClass)
					// Core
					|| BasicModel.class.isAssignableFrom(foreignClass)
					// Ugh
					|| EmptyModel.class.isAssignableFrom(foreignClass)
					) {

				String lookupName = VirtualDataviewServiceImpl.GGCE_PREFIX + foreignClass.getSimpleName() + VirtualLookupServiceImpl.LOOKUP_SUFFIX; // gget_TaxonomySpecies_lookup

				// Check for id field in foreignClass
				var foreignField = ReflectionUtils.findField(foreignClass, "id", Long.class);
				if (foreignField != null) {
					field.setForeignKeyDataviewName(lookupName);
					field.setIsForeignKey("Y");
					field.setGuiHint(GuiHint.LARGE_SINGLE_SELECT_CONTROL.name()); // Picker

					field = makeProxyForForeignKey(field, foreignClass); // FIXME This does not handle CooperatorOwnedLang.entity with @AssociationOverride

				} else { // No id field in foreignClass
					field.setGuiHint(GuiHint.INTEGER_CONTROL.name());

				}
			}
		}

		// Should this be filled with data?
//		field.setFieldOrdinal(); // can be null, leave it null
//		field.setDefaultValue(); // leave it null

		return field;
	}

	private SysTableField makeProxyForForeignKey(SysTableField field, Class<?> foreignClass) {
		ProxyFactory factory = new ProxyFactory();
		factory.setTarget(field);
		var methodNameAdvisor = new NameMatchMethodPointcutAdvisor();
		methodNameAdvisor.setMappedName("getForeignKeyTableField"); // this should limit execution to one method
		methodNameAdvisor.setAdvice(new DelegatingIntroductionInterceptor(field) {

			private static final long serialVersionUID = 2075892232786388301L;

			@Override
			public Object invoke(MethodInvocation mi) throws Throwable {
				log.trace("Doing stuff {} #{}", mi.getThis(), mi.getMethod().getName());
				// TODO Maybe foreign keys reference something else but "id"?
				var foreignField = ReflectionUtils.findField(foreignClass, "id", Long.class);
				if (foreignField == null) {
					log.error("No id field found in {}", foreignClass.getName());
					throw new RuntimeException("Type is missing field with name 'id'");
				}
				return getSysTableField(foreignField, foreignClass);
			}
		});
		factory.addAdvisor(methodNameAdvisor);
		return (SysTableField) factory.getProxy();
	}

	public enum FieldType {
		INTEGER, STRING, DATETIME, DECIMAL, LONG, GUID;

		public static FieldType typeOfField(Field field) {
			Class<?> fieldType = field.getType();
			if (fieldType == String.class) {
				return STRING;
			} else if (fieldType == Integer.class || fieldType == int.class) {
				return INTEGER;
			} else if (fieldType == Long.class || fieldType == long.class) {
				return LONG;
			} else if (fieldType == Date.class) {
				return DATETIME;
			} else if (fieldType == BigDecimal.class || fieldType == Double.class || fieldType == double.class || fieldType == Float.class || fieldType == float.class) {
				return DECIMAL;
			} else if (fieldType == Calendar.class) {
				return DATETIME;
			} else if (fieldType == Instant.class) {
				return DATETIME;
			} else if (fieldType == LocalDate.class) {
				return DATETIME;
			} else if (fieldType == UUID.class) {
				return GUID;
			} else if (fieldType == boolean.class) {
				return FieldType.STRING; // GG!
			} else if (fieldType.isEnum()) {
				return STRING;
			} else if (AuditedModel.class.isAssignableFrom(fieldType)) {
				return INTEGER;
			} else if (BasicModel.class.isAssignableFrom(fieldType)) {
				return LONG;
			} else if (EmptyModel.class.isAssignableFrom(fieldType)) {
				return LONG;
			}

			log.error("Unmanaged SysTableField {}.{} of type {}", field.getDeclaringClass().getName(), field.getName(), fieldType.getName());
			return STRING;
		}
	}

	public enum FieldPurpose {
		PRIMARY_KEY, AUTO_DATE_CREATE, AUTO_ASSIGN_OWN, AUTO_ASSIGN_CREATE,
		AUTO_ASSIGN_MODIFY, DATA, AUTO_DATE_MODIFY, AUTO_DATE_OWN
	}

	public enum DatabaseAreaCode {
		NULL(null), ACCESSION("Accession"), CROP("Crop"), INVENTORY("Inventory"),
		ORDER("Order"), OTHER("Other"), TAXONOMY("Taxonomy"), CITATION("Citation"),
		CODE("Code"), COOPERATOR("Cooperator"), DATAVIEW("Dataview"), GENETIC("Genetic"),
		GEOGRAPHIC("Geographic"), IMPORT_WIZARD("Import Wizard"), LOOKUP_TABLE("Lookup Table"),
		METHOD("Method"), SEARCH("Search"), SYSTEM("System"), SOURCE_HABITAT("Source/Habitat"),
		ACCESSION_INVENTORY("Accession/Inventory"), SITE("Site"), WEB("Web");

		public final String value;

		DatabaseAreaCode(String value) {
			this.value = value;
		}

		public static DatabaseAreaCode fromValue(String value) {
			var areaCode = NULL;
			for (DatabaseAreaCode databaseAreaCode : values()) {
				if (Objects.equals(databaseAreaCode.value, value)) {
					areaCode = databaseAreaCode;
					break;
				}
			}
			return areaCode;
		}
	}

	public enum GuiHint {
		DECIMAL_CONTROL, DATE_CONTROL, TOGGLE_CONTROL, TEXT_CONTROL,
		LARGE_SINGLE_SELECT_CONTROL, SMALL_SINGLE_SELECT_CONTROL, INTEGER_CONTROL;

		public static GuiHint guiHintFromField(Field targetField) {
			if (Double.class.isAssignableFrom(targetField.getType())) {
				return DECIMAL_CONTROL;
			}
			if (Float.class.isAssignableFrom(targetField.getType())) {
				return DECIMAL_CONTROL;
			}
			if (Number.class.isAssignableFrom(targetField.getType())) {
				return INTEGER_CONTROL;
			}
			if (Date.class.isAssignableFrom(targetField.getType())) {
				return DATE_CONTROL;
			}
			if (Calendar.class.isAssignableFrom(targetField.getType())) {
				return DATE_CONTROL;
			}
			if (targetField.getName().startsWith("is")) {
				return TOGGLE_CONTROL;
			}
			return TEXT_CONTROL;
		}
	}

	/**
	 * Generate a GRIN-Global compatible field name from Field definition.
	 * 
	 * @param targetField the field
	 * @return a GRIN-Global compatible field name
	 */
	private String getFieldGGName(Field targetField) {

		var columnAnno = targetField.getAnnotation(Column.class);
		if (Objects.nonNull(columnAnno) && StringUtils.isNotEmpty(columnAnno.name())) {
			// Has @Column with name
			return columnAnno.name();
		} else {
			// Is foreign key
			if (isForeignKey(targetField)) {
				var joinAnno = targetField.getAnnotation(JoinColumn.class);
				if (Objects.nonNull(joinAnno) && StringUtils.isNotEmpty(joinAnno.name())) {
					// Has @JoinColumn with name
					return joinAnno.name();
				} else {
					// Add _id to field, e.g. "private TaxonomySpecies taxonomySpecies" -->
					// "taxonomy_species_id"
					return targetField.getName() + "_id"; // in under_score_case
				}
			}
		}

		// Default to field name
		return targetField.getName(); // in under_score_case
	}

	private boolean isNullable(Field targetField) {
		if (targetField.getType().isPrimitive()) {
			return false;
		}

		if (Objects.nonNull(targetField.getAnnotation(Id.class))) {
			return false;
		}

		if (Objects.nonNull(targetField.getAnnotation(NotNull.class))) {
			return false;
		}

		var columnAnno = targetField.getAnnotation(Column.class);
		if (Objects.nonNull(columnAnno)) {
			return columnAnno.nullable();
		}

		var joinAnno = targetField.getAnnotation(JoinColumn.class);
		if (Objects.nonNull(joinAnno)) {
			return joinAnno.nullable();
		}

		return true;
	}

	private String getFieldPurpose(Field targetField) {

		// Map field to fieldPurpose
		if (Objects.nonNull(targetField.getAnnotation(Id.class))) {
			return "PRIMARY_KEY";
		} else if (Objects.nonNull(targetField.getAnnotation(LastModifiedDate.class))) {
			return "AUTO_DATE_MODIFY";
		} else if (Objects.nonNull(targetField.getAnnotation(CreatedDate.class))) {
			return "AUTO_DATE_CREATE";
		} else if (Objects.nonNull(targetField.getAnnotation(CreatedBy.class))) {
			return "AUTO_ASSIGN_CREATE"; // CT uses Cooperator.id which doesn't help
		} else if (Objects.nonNull(targetField.getAnnotation(LastModifiedBy.class))) {
			return "AUTO_ASSIGN_MODIFY"; // CT uses Cooperator.id which doesn't help
		} else if (targetField.getName().equals("ownedBy")) {
			return "AUTO_ASSIGN_OWN"; // CT uses Cooperator.id which doesn't help
		} else if (targetField.getName().equals("ownedDate")) {
			return "AUTO_DATE_OWN";
		}

		return "DATA";
	}

	private boolean isPrimaryKey(Field targetField) {
		return Objects.nonNull(targetField.getAnnotation(Id.class));
	}

	public static boolean isForeignKey(Field targetField) {
		return Objects.nonNull(targetField.getAnnotation(ManyToOne.class)) || Objects.nonNull(targetField.getAnnotation(OneToOne.class));
	}

	private int getMaxLength(Field targetField) {
		if (targetField.getAnnotation(Lob.class) != null) {
			return -1; // Lob's have no limit
		}
		var fieldType = targetField.getType();
		if (fieldType == String.class) {
			if (targetField.getName().startsWith("is")) { // GG boolean
				return 1;
			}
			var columnAnno = targetField.getAnnotation(Column.class);
			if (Objects.nonNull(columnAnno))
				return columnAnno.length();
			else
				return 255;
		}
		if (fieldType == Boolean.class || fieldType == boolean.class) {
			return 5; // 5 for 'false'
		}
		return 0;
	}

	private boolean isAutoincrement(Field targetField) {
		return Objects.nonNull(targetField.getAnnotation(GeneratedValue.class));
	}

	private Long getCachedId(String componentPrefix, String componentName) {
		String key = String.format("%s:%s", componentPrefix, componentName);
		var id = legacySystemPrimaryKeys.get(key);
		return id != null ? id : _generateId(componentPrefix, componentName);
	}

	private synchronized Long _generateId(String componentPrefix, String componentName) {
		String key = String.format("%s:%s", componentPrefix, componentName);
		String maxIdKey = String.format("maxId:%s", componentPrefix);
		var existingMaxId = legacySystemPrimaryKeys.computeIfAbsent(maxIdKey, (prefixKey) -> {
			log.trace("Initializing counter for pref={} key {}", componentPrefix, prefixKey);
			return 0L;
		});
		var id = legacySystemPrimaryKeys.get(key);
		if (id == null) {
			id = existingMaxId + 1;
			log.trace("Generating value for pref={} name={} ~= {}", componentPrefix, componentName, id);
			if (legacySystemPrimaryKeys.putIfAbsent(key, id) == null) {
				legacySystemPrimaryKeys.put(maxIdKey, id);
			}
		}
		return id;
	}
}