PublishableEntityAspect.java

/*
 * Copyright 2025 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.genesys.server.component.aspect;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import lombok.Getter;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.lang3.Strings;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.genesys.blocks.model.BasicModel;
import org.genesys.blocks.model.Publishable;
import org.genesys.blocks.util.TransactionHelper;
import org.genesys.server.api.v2.facade.APIFilteredTranslatedServiceFacade;
import org.genesys.server.api.v2.model.LangModelDTO;
import org.genesys.server.api.v2.model.Translated;
import org.genesys.server.model.impl.ITranslatedModel;
import org.genesys.server.model.impl.LangModel;
import org.genesys.server.service.TranslatorService;
import org.hibernate.Hibernate;
import org.hibernate.proxy.HibernateProxy;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.DependsOn;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Aspect
@DependsOn("currentApplicationContext")
@Slf4j
public class PublishableEntityAspect implements InitializingBean, ApplicationContextAware {

	/** Default language */
	private String defaultLanguageTag = "en";

	/** List of languages to auto-translate */
	private List<String> autoTranslate = List.of("en");

	@Autowired
	private ApplicationEventPublisher applicationEventPublisher;

	@Autowired
	private TransactionHelper transactionHelper;

	@SuppressWarnings("rawtypes")
	private Map<Class<?>, APIFilteredTranslatedServiceFacade<Object, Translated, LangModelDTO, BasicModel, ?, ?>> entityTranslationServicesMap;

	private ApplicationContext applicationContext;

	@Pointcut("saveActivityPost() || saveArticle() || saveSubset() || saveDataset() || saveDescriptor() || saveDescriptorList()")
	public void saveAutoTranslatedEntity() {}
	@Pointcut("saveAnyLang()")
	public void saveAutoTranslatedLang() {}

	@Pointcut("execution(* org.genesys.server.persistence.ArticleRepository.save(..)) || execution(* org.genesys.server.persistence.ArticleRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.ArticleRepository.saveAll(..)) || execution(* org.genesys.server.persistence.ArticleRepository.saveAllAndFlush(..))")
	public void saveArticle() {}
	@Pointcut("execution(* org.genesys.server.persistence.ActivityPostRepository.save(..)) || execution(* org.genesys.server.persistence.ActivityPostRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.ActivityPostRepository.saveAll(..)) || execution(* org.genesys.server.persistence.ActivityPostRepository.saveAllAndFlush(..))")
	public void saveActivityPost() {}
	@Pointcut("execution(* org.genesys.server.persistence.SubsetRepository.save(..)) || execution(* org.genesys.server.persistence.SubsetRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.SubsetRepository.saveAll(..)) || execution(* org.genesys.server.persistence.SubsetRepository.saveAllAndFlush(..))")
	public void saveSubset() {}
	@Pointcut("execution(* org.genesys.server.persistence.dataset.DatasetRepository.save(..)) || execution(* org.genesys.server.persistence.dataset.DatasetRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.dataset.DatasetRepository.saveAll(..)) || execution(* org.genesys.server.persistence.dataset.DatasetRepository.saveAllAndFlush(..))")
	public void saveDataset() {}
	@Pointcut("execution(* org.genesys.server.persistence.traits.DescriptorRepository.save(..)) || execution(* org.genesys.server.persistence.traits.DescriptorRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.traits.DescriptorRepository.saveAll(..)) || execution(* org.genesys.server.persistence.traits.DescriptorRepository.saveAllAndFlush(..))")
	public void saveDescriptor() {}
	@Pointcut("execution(* org.genesys.server.persistence.traits.DescriptorListRepository.save(..)) || execution(* org.genesys.server.persistence.traits.DescriptorListRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.traits.DescriptorListRepository.saveAll(..)) || execution(* org.genesys.server.persistence.traits.DescriptorListRepository.saveAllAndFlush(..))")
	public void saveDescriptorList() {}

	@Pointcut("execution(* org.genesys.server.persistence.LangModelRepository.save(..)) || execution(* org.genesys.server.persistence.LangModelRepository.saveAndFlush(..)) || execution(* org.genesys.server.persistence.LangModelRepository.saveAll(..)) || execution(* org.genesys.server.persistence.LangModelRepository.saveAllAndFlush(..))")
	public void saveAnyLang() {}
	// @Pointcut("execution(* org.genesys.server.persistence.ArticleLangRepository.save(..)) || execution(* org.genesys.server.persistence.ArticleLangRepository.saveAll(..))")
	// public void saveArticleLang() {}
	// @Pointcut("execution(* org.genesys.server.persistence.ActivityLangPostRepository.save(..)) || execution(* org.genesys.server.persistence.ActivityLangPostRepository.saveAll(..))")
	// public void saveActivityPostLang() {}
	// @Pointcut("execution(* org.genesys.server.persistence.SubsetLangRepository.save(..)) || execution(* org.genesys.server.persistence.SubsetLangRepository.saveAll(..))")
	// public void saveSubsetLang() {}
	// @Pointcut("execution(* org.genesys.server.persistence.DatasetLangRepository.save(..)) || execution(* org.genesys.server.persistence.DatasetLangRepository.saveAll(..))")
	// public void saveDatasetLang() {}
	// @Pointcut("execution(* org.genesys.server.persistence.DescriptorLangRepository.save(..)) || execution(* org.genesys.server.persistence.DescriptorLangRepository.saveAll(..))")
	// public void saveDescriptorLang() {}
	// @Pointcut("execution(* org.genesys.server.persistence.DescriptorListLangRepository.save(..)) || execution(* org.genesys.server.persistence.DescriptorListLangRepository.saveAll(..))")
	// public void saveDescriptorListLang() {}

	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
		this.applicationContext = applicationContext;
	}

	@Override
	public void afterPropertiesSet() throws Exception {
		@SuppressWarnings("rawtypes")
		Map<Class<?>, APIFilteredTranslatedServiceFacade<Object, Translated, LangModelDTO, BasicModel, ?, ?>> entityTranslationServicesMap = new HashMap<>();
		
		for (var translatedServiceName : applicationContext.getBeanNamesForType(APIFilteredTranslatedServiceFacade.class)) {
			@SuppressWarnings({ "unchecked", "rawtypes" })
			var serviceImpl = (APIFilteredTranslatedServiceFacade<Object, Translated, LangModelDTO, BasicModel, ?, ?>) applicationContext.getBean(translatedServiceName);
			var modelType = serviceImpl.getModelType();
			var modelLangType = serviceImpl.getLangModelType();
			if (isPublishableTranslatedType(modelType)) {
				log.warn("Registering automatic translation of {} with {} using {}", modelType.getName(), modelLangType.getName(), translatedServiceName);
				entityTranslationServicesMap.put(modelType, serviceImpl);
			} else {
				log.info("Ignoring service {} for {} using {}, not Publishable", translatedServiceName, modelType.getName(), modelLangType.getName());
			}
		}

		log.warn("{} is ready to auto-translate {} to {}", this.getClass().getName(), entityTranslationServicesMap.keySet(), this.autoTranslate);
		this.entityTranslationServicesMap = Collections.unmodifiableMap(entityTranslationServicesMap);
	}

	@AfterReturning(value = "saveAutoTranslatedEntity() || saveAutoTranslatedLang()", returning = "result")
	public void afterPersistingEntity(Object result) {
		log.debug("AfterReturning saveAutoTranslatedEntity(): {}", result);
		Set<ITranslatedModel> publishedEntities = new LinkedHashSet<>();
		if (result instanceof Collection) {
			Collection<?> entities = (Collection<?>) result;
			for (Object entity : entities) {
				processSavedEntity(entity, publishedEntities);
			}
		} else {
			processSavedEntity(result, publishedEntities);
		}

		if (!publishedEntities.isEmpty()) {
			log.info("Found {} published entities for translation", publishedEntities.size());
			applicationEventPublisher.publishEvent(new PublishedTranslatedEntitiesEvent(publishedEntities));
		}
	}

	private void processSavedEntity(Object entity, Set<ITranslatedModel> publishedEntities) {
		if (isPublishableTranslatedEntity(entity)) {
			var publishableEntity = (Publishable) entity;
			if (publishableEntity.isPublished()) {
				// Is published
				log.info("Published entity updated, should translate: {}", publishableEntity.getClass().getName());
				publishedEntities.add((ITranslatedModel) publishableEntity);
			} else {
				// Not published
				log.debug("Entity updated, will not auto-translate: published={} {}", publishableEntity.isPublished(), publishableEntity.getClass().getName());
			}

		} else if (entity instanceof LangModel) {
			// Update translations if the en lang model of a published entity is changed and is not machine translated
			var langModel = ((LangModel<?, ?>) entity);
			if (Strings.CS.equals(this.defaultLanguageTag, langModel.getLanguageTag()) && !langModel.isMachineTranslated()) {
				// Is curated translation in default language
				var targetEntity = langModel.getEntity();
				if (targetEntity instanceof HibernateProxy) {
					targetEntity = (BasicModel) Hibernate.unproxy(targetEntity);
				}
				if (isPublishableTranslatedEntity(targetEntity) && ((Publishable) targetEntity).isPublished()) {
					log.info("LangModel updated, should translate lang={} {} of {}", langModel.getLanguageTag(), langModel.getClass().getName(), targetEntity.getClass().getName());
					publishedEntities.add((ITranslatedModel) targetEntity);
				}
			} else {
				// Not a candidate for auto-translation
				log.debug("LangModel updated, will not auto-translate MT={} lang={} {}", langModel.isMachineTranslated(), langModel.getLanguageTag(), langModel.getClass().getName());
				return;
			}
		}
	}

	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void handlePublishedEntitiesEvent(PublishedTranslatedEntitiesEvent event) {
		if (event.getEntities() == null || event.getEntities().isEmpty()) {
			return;
		}
		log.info("Processing translations for {} published entities", event.getEntities().size());
		for (ITranslatedModel entity : event.getEntities()) {
			transactionHelper.executeInTransaction(false, () -> {
				processEntityTranslations(entity);
				return true;
			});
		}
	}

	private void processEntityTranslations(ITranslatedModel entity) throws TranslatorService.TranslatorException {

		var translationService = entityTranslationServicesMap.get(entity.getClass());
		if (translationService == null) {
			log.warn("No translation service found for entity {}", entity.getClass().getSimpleName());
			return;
		}

		// Note: The first language tag must be "en".
		for (String langTag : this.autoTranslate) {
			if (Strings.CS.equals(langTag, entity.getOriginalLanguageTag())) {
				// Don't translate original language
				continue;
			}
			var translation = translationService.loadTranslated((BasicModel) entity, langTag);
			var lang = translation.getTranslation();
			if (lang == null || lang.isMachineTranslated()) {
				log.warn("Auto-translating to {}: {} {}", langTag, entity.getClass().getSimpleName(), entity);
				// Update machine-translated text
				var updatedLang = translationService.machineTranslate((BasicModel) entity, langTag);
				translationService.upsertTranslation(translationService.get((BasicModel) entity), updatedLang);
			}
		}

	}

	@Getter
	public static class PublishedTranslatedEntitiesEvent {
		private final Set<ITranslatedModel> entities;

		public PublishedTranslatedEntitiesEvent(Set<ITranslatedModel> entities) {
			this.entities = entities;
		}
	}

	private boolean isPublishableTranslatedType(Class<?> type) {
		return Publishable.class.isAssignableFrom(type) && ITranslatedModel.class.isAssignableFrom(type);
	}

	private boolean isPublishableTranslatedEntity(Object o) {
		return o instanceof Publishable && o instanceof ITranslatedModel;
	}

	/** Set the default language on which other translations are based. Usually "en" */
	public void setDefaultLanguage(@NonNull String defaultLanguageTag) {
		this.defaultLanguageTag = defaultLanguageTag;
		setAutoTranslate(this.autoTranslate); // Update list of auto-translated languages to move default language to be the first
	}

	/** Set the languages to automatically translate */
  public void setAutoTranslate(@NonNull List<String> autoTranslated) {
		var autoTranslate = new LinkedList<String>();
		autoTranslated.stream().distinct().forEach(autoTranslate::add); // Unique
		// Ensure default language is first in the list
		if (autoTranslate.size() == 0 || ! Strings.CS.equals(this.defaultLanguageTag, autoTranslate.get(0))) {
			autoTranslate.remove(this.defaultLanguageTag);
			autoTranslate.add(0, this.defaultLanguageTag); // Default language is first
		}
		this.autoTranslate = Collections.unmodifiableList(autoTranslate);
  }
}