using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System.Text.Json; using UniVerse.Application.DTOs.Sync; using UniVerse.Application.Interfaces; using UniVerse.Domain.Entities; using UniVerse.Infrastructure.Data; namespace UniVerse.Infrastructure.Services; public class ScheduleSyncService : IScheduleSyncService { private readonly AppDbContext _db; private readonly IModeusApiClient _modeus; private readonly ILogger _logger; private static SyncStatusDto _lastStatus = new(null, "idle", null); public ScheduleSyncService(AppDbContext db, IModeusApiClient modeus, ILogger logger) { _db = db; _modeus = modeus; _logger = logger; } public async Task SyncScheduleAsync(SyncScheduleRequest request) { const string stage = "schedule"; int created = 0, updated = 0, skipped = 0; try { var events = await _modeus.SearchEventsAsync(request); var embeddedRoomCapacityById = BuildRoomCapacityLookup(events.Embedded?.Rooms); IReadOnlyDictionary? syncedRoomCapacityById = null; foreach (var ev in events.EventItems) { if (string.IsNullOrWhiteSpace(ev.Id) || string.IsNullOrWhiteSpace(ev.Name)) { skipped++; continue; } var courseUnitId = GetHrefId(ev.Links?.CourseUnitRealization?.Href); var courseUnit = events.Embedded?.CourseUnitRealizations? .FirstOrDefault(c => c.Id == courseUnitId); var courseExternalId = courseUnit?.Id ?? ev.TypeId ?? ev.Id; var courseName = courseUnit?.Name ?? ev.Name; var location = await UpsertEventLocationAsync(events, ev.Id); var roomId = GetEventRoomId(events, ev.Id); var maxEnrollments = GetRoomCapacity(embeddedRoomCapacityById, roomId); if (maxEnrollments is null && !string.IsNullOrWhiteSpace(roomId)) { syncedRoomCapacityById ??= await LoadRoomCapacityLookupAsync(); maxEnrollments = GetRoomCapacity(syncedRoomCapacityById, roomId); } var lectureCapacity = maxEnrollments ?? GetEventTeamSize(events, ev.Id) ?? 0; var startsAt = EnsureUtc(ev.StartsAt); var endsAt = EnsureUtc(ev.EndsAt); var existing = await _db.Lectures.FirstOrDefaultAsync(l => l.ExternalId == ev.Id); if (existing != null) { existing.Title = ev.Name; existing.Description = ev.Description; existing.StartsAt = startsAt; existing.EndsAt = endsAt; existing.LocationId = location?.Id; existing.MaxEnrollments = lectureCapacity; existing.UpdatedAt = DateTime.UtcNow; updated++; } else { var course = await _db.Courses.FirstOrDefaultAsync(c => c.ExternalId == courseExternalId); if (course == null) { course = new Course { Name = courseName, ExternalId = courseExternalId, IsSynced = true }; _db.Courses.Add(course); await _db.SaveChangesAsync(); } _db.Lectures.Add(new Lecture { CourseId = course.Id, LocationId = location?.Id, Title = ev.Name, Description = ev.Description, ExternalId = ev.Id, StartsAt = startsAt, EndsAt = endsAt, MaxEnrollments = lectureCapacity }); created++; } } await _db.SaveChangesAsync(); var result = new SyncResultDto(created, updated, skipped, null); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); return result; } catch (Exception ex) { _logger.LogError(ex, "Schedule sync failed"); var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) ? [] : request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( ex, stage, created, updated, skipped, [ "size=900", $"requestJson={BuildScheduleRequestJson(request)}", $"specialtyCode=[{string.Join(", ", specialtyCodes)}]", $"timeMin={request.TimeMin:O}", $"timeMax={request.TimeMax:O}", request.TypeId is { Count: > 0 } ? $"typeId=[{string.Join(", ", request.TypeId)}]" : "typeId=" ])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; } } public async Task SyncRoomsAsync() { const string stage = "rooms"; int created = 0, updated = 0, skipped = 0; try { var rooms = await _modeus.SearchRoomsAsync(); foreach (var room in rooms?.RoomItems ?? []) { if (room is null || string.IsNullOrWhiteSpace(room.Id) || string.IsNullOrWhiteSpace(room.Name)) { skipped++; continue; } var existing = await _db.Locations.FirstOrDefaultAsync(l => l.ExternalId == room.Id); if (existing != null) { existing.Name = room.Name; existing.Room = room.NameShort; existing.Building = room.Building?.Name ?? room.Building?.NameShort; existing.Address = room.Building?.Address; updated++; } else { _db.Locations.Add(new Location { Name = room.Name, Room = room.NameShort, Building = room.Building?.Name ?? room.Building?.NameShort, Address = room.Building?.Address, ExternalId = room.Id }); created++; } } await _db.SaveChangesAsync(); var result = new SyncResultDto(created, updated, skipped, null); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "completed", result); return result; } catch (Exception ex) { _logger.LogError(ex, "Rooms sync failed"); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails( ex, stage, created, updated, skipped, ["request=name:, sort:+building.name,+name, deleted:false, page size:100"])); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); return result; } } public async Task> SearchEmployeesAsync(string fullname) { var employees = await _modeus.SearchEmployeeAsync(fullname); return employees.Select(e => new EmployeeDto(e.Id, e.FullName, e.Department)).ToList(); } public Task GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); private async Task UpsertEventLocationAsync(ModeusEventsResponse events, string eventId) { var roomId = GetEventRoomId(events, eventId); if (string.IsNullOrWhiteSpace(roomId)) return null; var room = events.Embedded?.Rooms?.FirstOrDefault(item => item.Id == roomId); if (room == null || string.IsNullOrWhiteSpace(room.Name)) return null; var existing = await _db.Locations.FirstOrDefaultAsync(location => location.ExternalId == room.Id); if (existing != null) { existing.Name = room.Name; existing.Room = room.NameShort; existing.Building = room.Building?.Name ?? room.Building?.NameShort; existing.Address = room.Building?.Address; return existing; } var location = new Location { Name = room.Name, Room = room.NameShort, Building = room.Building?.Name ?? room.Building?.NameShort, Address = room.Building?.Address, ExternalId = room.Id }; _db.Locations.Add(location); await _db.SaveChangesAsync(); return location; } private static string? GetEventRoomId(ModeusEventsResponse events, string eventId) => events.Embedded?.EventRooms? .Select(eventRoom => new { EventId = GetHrefId(eventRoom.Links?.Event?.Href), RoomId = GetHrefId(eventRoom.Links?.Room?.Href) }) .FirstOrDefault(link => link.EventId == eventId) ?.RoomId; private async Task> LoadRoomCapacityLookupAsync() { try { var rooms = await _modeus.SearchRoomsAsync(); return BuildRoomCapacityLookup(rooms.RoomItems); } catch (Exception ex) { _logger.LogWarning(ex, "Could not load room capacities from Modeus rooms search."); return new Dictionary(); } } private static IReadOnlyDictionary BuildRoomCapacityLookup(IEnumerable? rooms) { var result = new Dictionary(); foreach (var room in rooms ?? []) { var capacity = NormalizeCapacity(room.WorkingCapacity) ?? NormalizeCapacity(room.TotalCapacity); if (!string.IsNullOrWhiteSpace(room.Id) && capacity.HasValue) result.TryAdd(room.Id, capacity.Value); } return result; } private static int? GetRoomCapacity(IReadOnlyDictionary roomCapacityById, string? roomId) => !string.IsNullOrWhiteSpace(roomId) && roomCapacityById.TryGetValue(roomId, out var capacity) ? capacity : null; private static int? GetEventTeamSize(ModeusEventsResponse events, string eventId) => NormalizeCapacity(events.Embedded?.EventTeams? .FirstOrDefault(team => team.EventId == eventId)?.Size); private static int? NormalizeCapacity(int? capacity) => capacity is > 0 ? capacity : null; private static IReadOnlyList BuildErrorDetails( Exception exception, string stage, int created, int updated, int skipped, IReadOnlyList context) { var details = new List { $"stage={stage}", $"exceptionType={exception.GetType().FullName}", $"message={exception.Message}", $"partialResult=created:{created}, updated:{updated}, skipped:{skipped}" }; if (exception is HttpRequestException httpException && httpException.StatusCode.HasValue) details.Add($"httpStatus={(int)httpException.StatusCode.Value} {httpException.StatusCode.Value}"); if (exception.InnerException != null) details.Add($"innerException={exception.InnerException.GetType().FullName}: {exception.InnerException.Message}"); details.AddRange(context); return details; } private static string BuildScheduleRequestJson(SyncScheduleRequest request) { var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode) ? [] : request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var body = new Dictionary { ["size"] = 900, ["timeMin"] = request.TimeMin, ["timeMax"] = request.TimeMax, ["specialtyCode"] = specialtyCodes }; if (request.TypeId is { Count: > 0 }) body["typeId"] = request.TypeId; return JsonSerializer.Serialize(body); } private static string? GetHrefId(string? href) { if (string.IsNullOrWhiteSpace(href)) return null; var index = href.LastIndexOf('/'); return index >= 0 && index < href.Length - 1 ? href[(index + 1)..] : href; } private static DateTime EnsureUtc(DateTime value) => value.Kind switch { DateTimeKind.Utc => value, DateTimeKind.Local => value.ToUniversalTime(), _ => DateTime.SpecifyKind(value, DateTimeKind.Local).ToUniversalTime() }; }