package com.paymentlink.service; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.paymentlink.model.dto.LocalizedProductDto; import com.paymentlink.model.entity.Product; import com.paymentlink.model.entity.Translation; import com.paymentlink.repository.TranslationRepository; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Service public class TranslationService { private static final Logger logger = LoggerFactory.getLogger(TranslationService.class); private final TranslationRepository translationRepository; private Cache translationCache; @Value("${i18n.cache.ttl:1800000}") private long cacheTtl; @Value("${i18n.default.language:en}") private String defaultLanguage; @Value("${i18n.supported.languages:en,es,fr,de,zh}") private String supportedLanguages; @Value("${i18n.fallback.enabled:true}") private boolean fallbackEnabled; public TranslationService(TranslationRepository translationRepository) { this.translationRepository = translationRepository; } @PostConstruct public void init() { // Initialize local cache (30 min TTL) translationCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(cacheTtl, TimeUnit.MILLISECONDS) .build(); logger.info("TranslationService initialized with cache TTL: {}ms, default language: {}, supported: {}", cacheTtl, defaultLanguage, supportedLanguages); } /** * Get translation for a specific entity field */ public String translate(String entityType, String entityId, String fieldName, String languageCode) { // Return null if requesting default language if (defaultLanguage.equalsIgnoreCase(languageCode)) { return null; // Caller should use original text } // Build cache key String cacheKey = buildCacheKey(entityType, entityId, fieldName, languageCode); // Check cache first String cached = translationCache.getIfPresent(cacheKey); if (cached != null) { return "".equals(cached) ? null : cached; // Empty string means no translation found } // Fetch from database Optional translationOpt = translationRepository .findByEntityTypeAndEntityIdAndFieldNameAndLanguageCode( entityType, entityId, fieldName, languageCode); if (translationOpt.isPresent()) { String translatedText = translationOpt.get().getTranslatedText(); translationCache.put(cacheKey, translatedText); return translatedText; } // Try fallback to English if enabled if (fallbackEnabled && !defaultLanguage.equalsIgnoreCase(languageCode)) { logger.debug("No translation found for {}/{}/{}/{}, falling back to default", entityType, entityId, fieldName, languageCode); } // Cache negative result to avoid repeated DB queries translationCache.put(cacheKey, ""); return null; } /** * Translate a single product */ public LocalizedProductDto translateProduct(Product product, String languageCode, String targetCurrency) { String translatedName = translate("product", String.valueOf(product.getId()), "name", languageCode); String translatedDescription = translate("product", String.valueOf(product.getId()), "description", languageCode); return LocalizedProductDto.builder() .id(product.getId()) .name(translatedName != null ? translatedName : product.getName()) .description(translatedDescription != null ? translatedDescription : product.getDescription()) .price(product.getPrice()) // Will be converted by caller .currency(targetCurrency != null ? targetCurrency : product.getCurrency()) .originalPrice(product.getPrice()) .originalCurrency(product.getCurrency()) .image(product.getImage()) .stock(product.getStock()) .category(product.getCategory()) .build(); } /** * Translate multiple products efficiently (bulk operation) */ public List bulkTranslate(List products, String languageCode, String targetCurrency) { if (products == null || products.isEmpty()) { return Collections.emptyList(); } // If default language, no translation needed if (defaultLanguage.equalsIgnoreCase(languageCode)) { return products.stream() .map(p -> translateProduct(p, languageCode, targetCurrency)) .collect(Collectors.toList()); } // Fetch all translations for these products at once List productIds = products.stream() .map(p -> String.valueOf(p.getId())) .collect(Collectors.toList()); List translations = translationRepository.findBulkTranslations( "product", productIds, languageCode); // Build a map for quick lookup Map> translationMap = new HashMap<>(); for (Translation t : translations) { translationMap .computeIfAbsent(t.getEntityId(), k -> new HashMap<>()) .put(t.getFieldName(), t.getTranslatedText()); } // Apply translations return products.stream() .map(product -> { String productId = String.valueOf(product.getId()); Map fields = translationMap.getOrDefault(productId, Collections.emptyMap()); return LocalizedProductDto.builder() .id(product.getId()) .name(fields.getOrDefault("name", product.getName())) .description(fields.getOrDefault("description", product.getDescription())) .price(product.getPrice()) .currency(targetCurrency != null ? targetCurrency : product.getCurrency()) .originalPrice(product.getPrice()) .originalCurrency(product.getCurrency()) .image(product.getImage()) .stock(product.getStock()) .category(fields.getOrDefault("category", product.getCategory())) .build(); }) .collect(Collectors.toList()); } /** * Save or update a translation */ @Transactional public Translation saveTranslation(String entityType, String entityId, String fieldName, String languageCode, String translatedText) { Optional existingOpt = translationRepository .findByEntityTypeAndEntityIdAndFieldNameAndLanguageCode( entityType, entityId, fieldName, languageCode); Translation translation; if (existingOpt.isPresent()) { translation = existingOpt.get(); translation.setTranslatedText(translatedText); } else { translation = Translation.builder() .entityType(entityType) .entityId(entityId) .fieldName(fieldName) .languageCode(languageCode) .translatedText(translatedText) .build(); } translation = translationRepository.save(translation); // Clear cache for this translation String cacheKey = buildCacheKey(entityType, entityId, fieldName, languageCode); translationCache.invalidate(cacheKey); logger.info("Saved translation: {}/{}/{}/{}", entityType, entityId, fieldName, languageCode); return translation; } /** * Get all translations for an entity */ public List getTranslationsForEntity(String entityType, String entityId) { return translationRepository.findByEntityTypeAndEntityId(entityType, entityId); } /** * Delete a translation */ @Transactional public void deleteTranslation(Long translationId) { Optional translationOpt = translationRepository.findById(translationId); if (translationOpt.isPresent()) { Translation translation = translationOpt.get(); String cacheKey = buildCacheKey( translation.getEntityType(), translation.getEntityId(), translation.getFieldName(), translation.getLanguageCode() ); translationCache.invalidate(cacheKey); translationRepository.deleteById(translationId); logger.info("Deleted translation: {}", translationId); } } /** * Get supported languages list */ public List getSupportedLanguages() { return Arrays.asList(supportedLanguages.split(",")); } /** * Check if language is supported */ public boolean isLanguageSupported(String languageCode) { return getSupportedLanguages().contains(languageCode.toLowerCase()); } /** * Clear translation cache */ public void clearCache() { translationCache.invalidateAll(); logger.info("Translation cache cleared"); } /** * Build cache key for translation */ private String buildCacheKey(String entityType, String entityId, String fieldName, String languageCode) { return String.format("%s:%s:%s:%s", entityType, entityId, fieldName, languageCode); } }