package com.paymentlink.service; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.Base64; import java.util.Set; import java.util.concurrent.TimeUnit; /** * Service for fetching and caching country flags * Fetches flags from external API and caches them locally */ @Service public class FlagService { private static final Logger logger = LoggerFactory.getLogger(FlagService.class); // Blocklist of territories we do not recognize private static final Set BLOCKED_TERRITORIES = Set.of( "EH", // Western Sahara "PS", // Palestine "TW" // Taiwan (Republic of China) ); private final RestTemplate restTemplate; private final String flagApiUrl; private final Path flagCacheDir; // In-memory cache for base64 encoded flags private final Cache flagCache; public FlagService( RestTemplate restTemplate, @Value("${flags.api.url:https://flagcdn.com/w40}") String flagApiUrl, @Value("${flags.cache.directory:flags}") String flagCacheDirectory) { this.restTemplate = restTemplate; this.flagApiUrl = flagApiUrl; this.flagCacheDir = Paths.get(flagCacheDirectory); // Create cache directory if it doesn't exist try { Files.createDirectories(flagCacheDir); } catch (IOException e) { logger.error("Failed to create flag cache directory", e); } // Initialize in-memory cache (30 days TTL) this.flagCache = Caffeine.newBuilder() .expireAfterWrite(30, TimeUnit.DAYS) .maximumSize(500) .build(); logger.info("FlagService initialized with cache directory: {}", flagCacheDir.toAbsolutePath()); } /** * Get flag for a country code as base64 data URL * * @param countryCode ISO 3166-1 alpha-2 country code (e.g., "US", "TR") * @return Base64 data URL or null if not found or blocked */ public String getFlag(String countryCode) { if (countryCode == null || countryCode.length() != 2) { return null; } String code = countryCode.toLowerCase(); String upperCode = countryCode.toUpperCase(); // Check if territory is blocked if (BLOCKED_TERRITORIES.contains(upperCode)) { logger.debug("Flag request blocked for non-recognized territory: {}", upperCode); return null; } // Check in-memory cache first String cachedFlag = flagCache.getIfPresent(code); if (cachedFlag != null) { return cachedFlag; } // Check disk cache String diskFlag = loadFromDisk(code); if (diskFlag != null) { flagCache.put(code, diskFlag); return diskFlag; } // Fetch from external API String fetchedFlag = fetchFlagFromApi(code); if (fetchedFlag != null) { flagCache.put(code, fetchedFlag); saveToDisk(code, fetchedFlag); return fetchedFlag; } return null; } /** * Fetch flag from external API */ private String fetchFlagFromApi(String countryCode) { try { String url = String.format("%s/%s.png", flagApiUrl, countryCode); logger.debug("Fetching flag for {} from {}", countryCode.toUpperCase(), url); ResponseEntity response = restTemplate.exchange( url, HttpMethod.GET, null, byte[].class ); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { byte[] imageBytes = response.getBody(); String base64 = Base64.getEncoder().encodeToString(imageBytes); String dataUrl = "data:image/png;base64," + base64; logger.info("Successfully fetched flag for {}", countryCode.toUpperCase()); return dataUrl; } } catch (Exception e) { logger.warn("Failed to fetch flag for {} from external API: {}", countryCode.toUpperCase(), e.getMessage()); } return null; } /** * Load flag from disk cache */ private String loadFromDisk(String countryCode) { try { Path flagFile = flagCacheDir.resolve(countryCode + ".txt"); if (Files.exists(flagFile)) { String dataUrl = Files.readString(flagFile); logger.debug("Loaded flag for {} from disk cache", countryCode.toUpperCase()); return dataUrl; } } catch (IOException e) { logger.warn("Failed to load flag for {} from disk: {}", countryCode.toUpperCase(), e.getMessage()); } return null; } /** * Save flag to disk cache */ private void saveToDisk(String countryCode, String dataUrl) { try { Path flagFile = flagCacheDir.resolve(countryCode + ".txt"); Files.writeString(flagFile, dataUrl); logger.debug("Saved flag for {} to disk cache", countryCode.toUpperCase()); } catch (IOException e) { logger.warn("Failed to save flag for {} to disk: {}", countryCode.toUpperCase(), e.getMessage()); } } /** * Get SVG fallback icon for countries without flags */ public String getFallbackIcon() { return """ """; } /** * Check if a territory is blocked (not recognized) * * @param countryCode ISO 3166-1 alpha-2 country code * @return true if blocked, false otherwise */ public boolean isBlocked(String countryCode) { if (countryCode == null || countryCode.length() != 2) { return false; } return BLOCKED_TERRITORIES.contains(countryCode.toUpperCase()); } /** * Get list of blocked territories * * @return Set of blocked country codes */ public Set getBlockedTerritories() { return Set.copyOf(BLOCKED_TERRITORIES); } /** * Prefetch flags for all given country codes * Useful for warming up the cache on application startup */ public void prefetchFlags(String... countryCodes) { for (String code : countryCodes) { if (code != null && code.length() == 2 && !isBlocked(code)) { // This will fetch and cache if not already cached getFlag(code); } } } /** * Clear all cached flags (memory and disk) */ public void clearCache() { flagCache.invalidateAll(); try { Files.list(flagCacheDir) .filter(path -> path.toString().endsWith(".txt")) .forEach(path -> { try { Files.delete(path); } catch (IOException e) { logger.warn("Failed to delete cached flag: {}", path); } }); logger.info("Flag cache cleared"); } catch (IOException e) { logger.error("Failed to clear flag cache", e); } } /** * Get cache statistics */ public CacheStats getCacheStats() { return new CacheStats( flagCache.estimatedSize(), flagCache.stats().hitCount(), flagCache.stats().missCount(), countDiskCacheFiles() ); } private long countDiskCacheFiles() { try { return Files.list(flagCacheDir) .filter(path -> path.toString().endsWith(".txt")) .count(); } catch (IOException e) { return 0; } } public record CacheStats( long memorySize, long hitCount, long missCount, long diskFiles ) {} }