using System.Collections.Concurrent; namespace Campaign_Tracker.Server.ElectionCycles; public sealed class InMemoryElectionCycleJobRepository : IElectionCycleJobRepository { private const string StatusInSetup = "In Setup"; private static readonly Dictionary StatusSortOrder = new(StringComparer.OrdinalIgnoreCase) { [StatusInSetup] = 0, ["Ready"] = 1, ["In progress"] = 2, ["At risk"] = 3, ["Complete"] = 4, ["Blocked"] = 5, }; private readonly ConcurrentDictionary _jobs = new(StringComparer.OrdinalIgnoreCase); private readonly TimeProvider _timeProvider; // Seed data for kanban read model compatibility (Story 2.1). private readonly List _seedAssignments = [ new( JobId: "job-fair01-primary", JCode: "FAIR01", CycleId: "2026-primary", CycleName: "2026 Primary", Status: "Ready", IsActive: true), new( JobId: "job-lake02-primary", JCode: "LAKE02", CycleId: "2026-primary", CycleName: "2026 Primary", Status: "In progress", IsActive: true), new( JobId: "job-lake02-special", JCode: "LAKE02", CycleId: "2026-special", CycleName: "2026 Special", Status: "At risk", IsActive: true), new( JobId: "job-pine03-2024", JCode: "PINE03", CycleId: "2024-general", CycleName: "2024 General", Status: "Complete", IsActive: false), ]; public InMemoryElectionCycleJobRepository(TimeProvider timeProvider) { _timeProvider = timeProvider; } // Constructor for tests that provide pre-seeded assignments. // Clears default seed data so tests control the exact dataset. public InMemoryElectionCycleJobRepository( IReadOnlyList additionalAssignments, TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; _seedAssignments.Clear(); foreach (var a in additionalAssignments) { var job = new ElectionCycleJob( JobId: a.JobId, JCode: a.JCode, CycleId: a.CycleId, CycleName: a.CycleName, Status: a.Status, CreatedBy: "seed", CreatedAt: _timeProvider.GetUtcNow()); _jobs[job.JobId] = job; _seedAssignments.Add(a); } } public Task> GetAllAsync( CancellationToken cancellationToken = default) { // Return seed assignments plus any dynamically created jobs not already in seeds. var jobIds = _seedAssignments.Select(a => a.JobId).ToHashSet(StringComparer.OrdinalIgnoreCase); var dynamicJobs = _jobs.Values .Where(j => !jobIds.Contains(j.JobId)) .Select(MapToAssignment) .ToArray(); var all = _seedAssignments.Concat(dynamicJobs).ToArray(); return Task.FromResult>(all); } public Task CreateAsync( string jCode, string cycleId, string cycleName, string actorIdentity, CancellationToken cancellationToken = default) { var error = Validate(jCode, cycleId, cycleName); if (error is not null) return Task.FromResult(ElectionCycleJobSaveResult.Failure(error)); var now = _timeProvider.GetUtcNow(); var jobId = $"job-{jCode.ToLowerInvariant()}-{cycleId.ToLowerInvariant()}"; // Idempotency: if a job with this composite key already exists, return it. if (_jobs.TryGetValue(jobId, out var existing)) return Task.FromResult(ElectionCycleJobSaveResult.Success(existing)); var job = new ElectionCycleJob( JobId: jobId, JCode: jCode.Trim().ToUpperInvariant(), CycleId: cycleId.Trim(), CycleName: cycleName.Trim(), Status: StatusInSetup, CreatedBy: actorIdentity, CreatedAt: now); _jobs[jobId] = job; return Task.FromResult(ElectionCycleJobSaveResult.Success(job)); } private static string? Validate(string jCode, string cycleId, string cycleName) { if (string.IsNullOrWhiteSpace(jCode)) return "Municipality identifier (JCode) is required."; if (string.IsNullOrWhiteSpace(cycleId)) return "Cycle selection is required."; if (string.IsNullOrWhiteSpace(cycleName)) return "Cycle name is required."; return null; } private static ElectionCycleJobAssignment MapToAssignment(ElectionCycleJob job) => new( JobId: job.JobId, JCode: job.JCode, CycleId: job.CycleId, CycleName: job.CycleName, Status: job.Status, IsActive: true); }