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); 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 maxEnrollments = events.Embedded?.EventTeams? .FirstOrDefault(team => team.EventId == ev.Id)?.Size ?? 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 = maxEnrollments; 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 = maxEnrollments }); 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 = events.Embedded?.EventRooms? .Select(eventRoom => new { EventId = GetHrefId(eventRoom.Links?.Event?.Href), RoomId = GetHrefId(eventRoom.Links?.Room?.Href) }) .FirstOrDefault(link => link.EventId == eventId) ?.RoomId; 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 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() }; }