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; } = [];
}