using System.Collections.Immutable; using System.Text.Json.Serialization; namespace StellaOps.Notify.Models; /// /// A localization bundle containing translated strings for a specific locale. /// public sealed record NotifyLocalizationBundle { [JsonConstructor] public NotifyLocalizationBundle( string bundleId, string tenantId, string locale, string bundleKey, ImmutableDictionary strings, bool isDefault = false, string? parentLocale = null, string? description = null, ImmutableDictionary? metadata = null, string? createdBy = null, DateTimeOffset? createdAt = null, string? updatedBy = null, DateTimeOffset? updatedAt = null) { BundleId = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleId, nameof(bundleId)); TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId)); Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant(); BundleKey = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleKey, nameof(bundleKey)); Strings = strings; IsDefault = isDefault; ParentLocale = NormalizeParentLocale(parentLocale, Locale); Description = NotifyValidation.TrimToNull(description); Metadata = NotifyValidation.NormalizeStringDictionary(metadata); CreatedBy = NotifyValidation.TrimToNull(createdBy); CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow); UpdatedBy = NotifyValidation.TrimToNull(updatedBy); UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt); } public static NotifyLocalizationBundle Create( string bundleId, string tenantId, string locale, string bundleKey, IEnumerable>? strings = null, bool isDefault = false, string? parentLocale = null, string? description = null, IEnumerable>? metadata = null, string? createdBy = null, DateTimeOffset? createdAt = null, string? updatedBy = null, DateTimeOffset? updatedAt = null) { return new NotifyLocalizationBundle( bundleId, tenantId, locale, bundleKey, ToImmutableDictionary(strings) ?? ImmutableDictionary.Empty, isDefault, parentLocale, description, ToImmutableDictionary(metadata), createdBy, createdAt, updatedBy, updatedAt); } /// /// Unique identifier for this bundle. /// public string BundleId { get; } /// /// Tenant ID this bundle belongs to. /// public string TenantId { get; } /// /// Locale code (e.g., "en-us", "fr-fr", "ja-jp"). /// public string Locale { get; } /// /// Bundle key for grouping related bundles (e.g., "notifications", "email-subjects"). /// public string BundleKey { get; } /// /// Dictionary of string key to translated value. /// public ImmutableDictionary Strings { get; } /// /// Whether this is the default/fallback bundle for the bundle key. /// public bool IsDefault { get; } /// /// Parent locale for fallback chain (e.g., "en" for "en-us"). /// Automatically computed if not specified. /// public string? ParentLocale { get; } public string? Description { get; } public ImmutableDictionary Metadata { get; } public string? CreatedBy { get; } public DateTimeOffset CreatedAt { get; } public string? UpdatedBy { get; } public DateTimeOffset UpdatedAt { get; } /// /// Gets a localized string by key. /// public string? GetString(string key) { return Strings.TryGetValue(key, out var value) ? value : null; } /// /// Gets a localized string by key with a default fallback. /// public string GetString(string key, string defaultValue) { return Strings.TryGetValue(key, out var value) ? value : defaultValue; } private static string? NormalizeParentLocale(string? parentLocale, string locale) { if (!string.IsNullOrWhiteSpace(parentLocale)) { return parentLocale.ToLowerInvariant(); } // Auto-compute parent locale from locale // e.g., "en-us" -> "en", "pt-br" -> "pt" var dashIndex = locale.IndexOf('-'); if (dashIndex > 0) { return locale[..dashIndex]; } return null; } private static ImmutableDictionary? ToImmutableDictionary(IEnumerable>? pairs) { if (pairs is null) { return null; } var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var (key, value) in pairs) { builder[key] = value; } return builder.ToImmutable(); } } /// /// Service for resolving localized strings with fallback chain support. /// public interface ILocalizationResolver { /// /// Resolves a localized string using the fallback chain. /// /// The tenant ID. /// The bundle key. /// The string key within the bundle. /// The preferred locale. /// Cancellation token. /// The resolved string or null if not found. Task ResolveAsync( string tenantId, string bundleKey, string stringKey, string locale, CancellationToken cancellationToken = default); /// /// Resolves multiple strings at once for efficiency. /// Task> ResolveBatchAsync( string tenantId, string bundleKey, IEnumerable stringKeys, string locale, CancellationToken cancellationToken = default); } /// /// Result of a localization resolution. /// public sealed record LocalizedString { /// /// The resolved string value. /// public required string Value { get; init; } /// /// The locale that provided the value. /// public required string ResolvedLocale { get; init; } /// /// The originally requested locale. /// public required string RequestedLocale { get; init; } /// /// Whether fallback was used. /// public bool UsedFallback => !ResolvedLocale.Equals(RequestedLocale, StringComparison.OrdinalIgnoreCase); /// /// The fallback chain that was traversed. /// public IReadOnlyList FallbackChain { get; init; } = []; }