using System.Collections.Concurrent; using Campaign_Tracker.Server.ExtensionData; using Campaign_Tracker.Server.LegacyData; namespace Campaign_Tracker.Server.Municipalities; /// /// In-memory municipality profile store for development and integration testing. /// Implements so profiles are included in /// the nightly extension-to-legacy link integrity check (Story 1.8 AC #4). /// public sealed class InMemoryMunicipalityProfileRepository : IMunicipalityProfileRepository, ILegacyLinkedRecordProvider { private readonly ConcurrentDictionary _profiles = new(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new(); private readonly ILegacyLinkValidator _validator; private readonly ILegacyDataAccess _legacyData; private readonly TimeProvider _timeProvider; public InMemoryMunicipalityProfileRepository( ILegacyLinkValidator validator, ILegacyDataAccess legacyData, TimeProvider timeProvider) { _validator = validator; _legacyData = legacyData; _timeProvider = timeProvider; } // ── AC #1: create with required JCode link ──────────────────────────────── public async Task CreateAsync( string jCode, string? displayName, string actorIdentity, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(jCode)) return MunicipalityProfileSaveResult.Failure("JCode is required."); // P8: normalize before validation so the link validator uses the same form that gets stored var normalizedJCode = jCode.Trim().ToUpperInvariant(); // AC #4: validate before saving; never write if the link is invalid var linkRef = LegacyLinkReference.ForJurisdiction(normalizedJCode); var validation = await _validator.ValidateAsync(linkRef, cancellationToken); if (!validation.IsValid) return MunicipalityProfileSaveResult.Failure(validation.Error!); var now = _timeProvider.GetUtcNow(); var profile = new MunicipalityProfile( ProfileId: Guid.NewGuid().ToString("N"), JCode: normalizedJCode, DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), CreatedAt: now, UpdatedAt: now, UpdatedBy: actorIdentity); // P2: atomic check + insert under lock to prevent TOCTOU race on duplicate JCode lock (_lock) { if (_profiles.Values.Any(p => string.Equals(p.JCode, normalizedJCode, StringComparison.OrdinalIgnoreCase))) return MunicipalityProfileSaveResult.Failure( $"A municipality profile already exists for JCode '{normalizedJCode}'."); _profiles[profile.ProfileId] = profile; } return MunicipalityProfileSaveResult.Success(profile); } // ── AC #3: update with audit trail captured by caller ──────────────────── public Task UpdateAsync( string profileId, string? displayName, string actorIdentity, CancellationToken cancellationToken = default) { // P2: wrap read-modify-write in lock to prevent lost updates under concurrent PUTs lock (_lock) { if (!_profiles.TryGetValue(profileId, out var existing)) return Task.FromResult(MunicipalityProfileSaveResult.ProfileNotFound(profileId)); var updated = existing with { DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), UpdatedAt = _timeProvider.GetUtcNow(), UpdatedBy = actorIdentity, }; _profiles[profileId] = updated; return Task.FromResult(MunicipalityProfileSaveResult.Success(updated)); } } // ── AC #2: resolve combined extension + legacy view ────────────────────── public async Task GetByIdAsync( string profileId, CancellationToken cancellationToken = default) { if (!_profiles.TryGetValue(profileId, out var profile)) return null; return await BuildViewAsync(profile, cancellationToken); } public async Task> GetAllAsync( CancellationToken cancellationToken = default) { var profiles = _profiles.Values .OrderBy(p => p.JCode, StringComparer.OrdinalIgnoreCase) .ToArray(); var views = new List(profiles.Length); foreach (var profile in profiles) views.Add(await BuildViewAsync(profile, cancellationToken)); return views; } // ── ILegacyLinkedRecordProvider ─────────────────────────────────────────── Task> ILegacyLinkedRecordProvider.GetAllAsync( CancellationToken cancellationToken) => Task.FromResult>( _profiles.Values.Cast().ToArray()); // ── helpers ─────────────────────────────────────────────────────────────── private async Task BuildViewAsync( MunicipalityProfile profile, CancellationToken cancellationToken) { var jurisdiction = await _legacyData.GetJurisdictionAsync(profile.JCode, cancellationToken); return new MunicipalityProfileView( Profile: profile, LegacyName: jurisdiction?.Name, LegacyMailingAddress: jurisdiction?.MailingAddress, LegacyCityStateZip: jurisdiction?.CityStateZip); } }