using System.Collections.Concurrent; using System.Globalization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.Localization; /// /// Singleton that holds all loaded translation bundles and resolves keys. /// Priority cascade: DB overrides (100) > service-local JSON (10) > common JSON (0). /// Fallback chain: requested locale -> language-only -> default locale -> key itself. /// public sealed class TranslationRegistry { // locale -> (key -> value), already merged by priority private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase); private readonly TranslationOptions _options; private readonly ILogger _logger; public TranslationRegistry(IOptions options, ILogger logger) { _options = options?.Value ?? new TranslationOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Loads all bundles from registered providers in priority order (lowest first). /// public async Task LoadAsync(IEnumerable providers, CancellationToken ct) { var ordered = providers.OrderBy(p => p.Priority).ToList(); // Collect all available locales from all providers var allLocales = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var provider in ordered) { var locales = await provider.GetAvailableLocalesAsync(ct).ConfigureAwait(false); foreach (var locale in locales) { allLocales.Add(locale); } } // Ensure default locale is always loaded allLocales.Add(_options.DefaultLocale); // Load bundles in priority order (lower first, higher overwrites). // Locales within the same provider are independent, so load them concurrently // and merge back in deterministic locale order. foreach (var provider in ordered) { var loadTasks = allLocales .OrderBy(locale => locale, StringComparer.OrdinalIgnoreCase) .Select(locale => LoadProviderBundleAsync(provider, locale, ct)) .ToArray(); var results = await Task.WhenAll(loadTasks).ConfigureAwait(false); foreach (var result in results) { if (result.Bundle.Count == 0) { continue; } MergeBundles(result.Locale, result.Bundle); _logger.LogDebug( "Loaded {Count} translations for locale {Locale} from provider (priority {Priority})", result.Bundle.Count, result.Locale, provider.Priority); } } var totalKeys = _store.Values.Sum(d => d.Count); _logger.LogInformation( "Translation registry loaded: {LocaleCount} locales, {TotalKeys} total keys", _store.Count, totalKeys); } private async Task LoadProviderBundleAsync( ITranslationBundleProvider provider, string locale, CancellationToken ct) { try { var bundle = await provider.LoadAsync(locale, ct).ConfigureAwait(false); return new ProviderLocaleBundle(locale, bundle); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load translations for locale {Locale} from provider (priority {Priority})", locale, provider.Priority); return new ProviderLocaleBundle( locale, new Dictionary(StringComparer.Ordinal)); } } /// /// Merges a bundle into the store. Higher-priority values overwrite lower. /// public void MergeBundles(string locale, IReadOnlyDictionary strings) { var dict = _store.GetOrAdd(locale, _ => new ConcurrentDictionary(StringComparer.Ordinal)); foreach (var (key, value) in strings) { dict[key] = value; } } /// /// Translates a key with positional parameters. /// Uses the current request locale from or the default. /// public string Translate(string key, object?[] args) { var locale = LocaleContext.Current ?? _options.DefaultLocale; var template = Resolve(key, locale); if (args.Length == 0) { return template; } try { return string.Format(CultureInfo.GetCultureInfo(locale), template, args); } catch (FormatException) { _logger.LogWarning("Format error translating key {Key} with {ArgCount} args for locale {Locale}", key, args.Length, locale); return template; } } /// /// Translates a key with named parameters. /// Replaces {name} placeholders with formatted values. /// public string TranslateNamed(string key, (string Name, object Value)[] namedArgs) { var locale = LocaleContext.Current ?? _options.DefaultLocale; var template = Resolve(key, locale); foreach (var (name, value) in namedArgs) { var placeholder = $"{{{name}}}"; template = template.Replace(placeholder, FormatValue(value, locale), StringComparison.Ordinal); } return template; } /// /// Returns all translations for a locale (merged from all providers). /// public IReadOnlyDictionary GetBundle(string locale) { if (_store.TryGetValue(locale, out var dict)) { return new Dictionary(dict, StringComparer.Ordinal); } return new Dictionary(StringComparer.Ordinal); } /// /// Returns translations for a locale filtered by key prefix. /// public IReadOnlyDictionary GetBundle(string locale, string namespacePrefix) { var prefix = namespacePrefix.EndsWith('.') ? namespacePrefix : namespacePrefix + "."; var result = new Dictionary(StringComparer.Ordinal); if (_store.TryGetValue(locale, out var dict)) { foreach (var (key, value) in dict) { if (key.StartsWith(prefix, StringComparison.Ordinal)) { result[key] = value; } } } return result; } /// /// Returns all loaded locales. /// public IReadOnlyList GetLoadedLocales() { return _store.Keys.OrderBy(l => l).ToList(); } /// /// Resolves a key through the fallback chain: /// requested locale -> language-only -> default locale -> key itself. /// private string Resolve(string key, string locale) { // 1. Try exact locale (e.g., "de-DE") if (TryGetValue(locale, key, out var value)) { return value; } // 2. Try language-only (e.g., "de") var dashIndex = locale.IndexOf('-'); if (dashIndex > 0) { var languageOnly = locale[..dashIndex]; if (TryGetValue(languageOnly, key, out value)) { return value; } } // 3. Try default locale (e.g., "en-US") if (!string.Equals(locale, _options.DefaultLocale, StringComparison.OrdinalIgnoreCase)) { if (TryGetValue(_options.DefaultLocale, key, out value)) { return value; } // 4. Try default language-only (e.g., "en") var defaultDash = _options.DefaultLocale.IndexOf('-'); if (defaultDash > 0) { var defaultLang = _options.DefaultLocale[..defaultDash]; if (TryGetValue(defaultLang, key, out value)) { return value; } } } // 5. Return key itself as fallback if (_options.ReturnKeyWhenMissing) { return key; } return key; } private bool TryGetValue(string locale, string key, out string value) { value = string.Empty; return _store.TryGetValue(locale, out var dict) && dict.TryGetValue(key, out value!); } private static string FormatValue(object value, string locale) { var culture = CultureInfo.GetCultureInfo(locale); return value switch { null => string.Empty, DateTime dt => dt.ToString("g", culture), DateTimeOffset dto => dto.ToString("g", culture), int i => i.ToString("N0", culture), long l => l.ToString("N0", culture), decimal d => d.ToString("N2", culture), double dbl => dbl.ToString("N2", culture), float f => f.ToString("N2", culture), _ => value.ToString() ?? string.Empty }; } private sealed record ProviderLocaleBundle(string Locale, IReadOnlyDictionary Bundle); }