using System.Collections.Concurrent;
using Email.Data.Repositories.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Email.Data.Services;
///
/// Singleton implementation of that caches all email templates
/// from the database and refreshes them every 10 minutes.
/// Uses to resolve the scoped repository from a singleton lifetime.
///
public sealed class EmailTemplateService : IEmailTemplateService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
private ConcurrentDictionary _valueCache = new(StringComparer.OrdinalIgnoreCase);
private ConcurrentDictionary _operatorCache = new(StringComparer.OrdinalIgnoreCase);
private DateTime _loadedAt = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
///
public string Get(string key, string language = "en")
{
EnsureCacheLoaded();
if (_valueCache.TryGetValue(CacheKey(key, language), out var value))
return value;
if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
&& _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback))
return fallback;
_logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language);
return key;
}
///
public string Render(string key, string language, params (string Key, string Value)[] placeholders)
{
var template = Get(key, language);
foreach (var (k, v) in placeholders)
template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase);
return template;
}
///
public string? GetOperatorCopy(string key, string language)
{
EnsureCacheLoaded();
if (_operatorCache.TryGetValue(CacheKey(key, language), out var specific)
&& !string.IsNullOrWhiteSpace(specific))
return specific;
// Fall back to first non-empty OperatorCopy in the cache
foreach (var val in _operatorCache.Values)
{
if (!string.IsNullOrWhiteSpace(val))
return val;
}
return null;
}
///
/// Reloads all templates from the database when the cache TTL has expired.
/// Swaps both caches atomically; logs an error and continues serving the stale cache on failure.
///
private void EnsureCacheLoaded()
{
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService();
var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult();
var freshValues = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
var freshOperator = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
freshValues[CacheKey(row.Key, row.Language)] = row.Value;
freshOperator[CacheKey(row.Key, row.Language)] = row.OperatorCopy;
}
_valueCache = freshValues;
_operatorCache = freshOperator;
_loadedAt = DateTime.UtcNow;
_logger.LogDebug("Email template cache refreshed. {Count} templates loaded.", rows.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh email template cache. Serving stale cache.");
}
}
/// Builds the dictionary key used for both caches.
private static string CacheKey(string key, string language) => $"{key}::{language}";
}