using Fitness_core_BE.DTOs.Facility; using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using System.Text.Json; namespace Fitness_core_BE.Services { public class FacilityService : IFacilityService { private readonly IFacilityRepository _facilityRepository; private readonly IClassService _classService; private readonly IBookingService _bookingService; private readonly IUnitOfWork _uow; private readonly UserRoleHelper _userRoleHelper; public FacilityService( IFacilityRepository facilityRepository, IClassService classService, IBookingService bookingService, IUnitOfWork uow, UserRoleHelper userRoleHelper) { _facilityRepository = facilityRepository; _classService = classService; _bookingService = bookingService; _uow = uow; _userRoleHelper = userRoleHelper; } public async Task GetAllFacilities(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive); var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(facilities.Select(f => f.FacilityId).ToList()); return facilities.Select(f => MapToDto(f, devicesByFacility.GetValueOrDefault(f.FacilityId))).ToList(); } public async Task CreateFacility(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); var supportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : true; var supportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : false; if (!supportsClasses && !supportsBooking) throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true"); var defaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1; if (supportsClasses && defaultCapacity < 1) throw new ArgumentException("defaultCapacity must be at least 1"); var numberOfUnits = data.ContainsKey("numberOfUnits") ? data["numberOfUnits"].GetInt32() : 1; if (supportsBooking && numberOfUnits < 1) throw new ArgumentException("numberOfUnits must be at least 1"); var pricePerHour = data.ContainsKey("pricePerHour") ? data["pricePerHour"].GetDecimal() : 0m; if (pricePerHour < 0) throw new ArgumentException("pricePerHour cannot be negative"); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facility = new Facility { FKClientId = clientId, Name = data["name"].GetString() ?? "", SupportsClasses = supportsClasses, SupportsBooking = supportsBooking, Description = data.ContainsKey("description") ? data["description"].GetString() : null, DefaultCapacity = defaultCapacity, NumberOfUnits = numberOfUnits, PricePerHour = pricePerHour }; var result = await _facilityRepository.CreateFacility(facility); await _uow.CommitAsync(); return MapToDto(result, null); } catch { await _uow.RollbackAsync(); throw; } } public async Task UpdateFacility(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required"); var facilityId = Guid.Parse(data["facilityId"].GetString()!); var existing = await _facilityRepository.GetFacilityById(facilityId) ?? throw new KeyNotFoundException("Facility not found"); if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name; // Validate on the merged result, not each incoming field in isolation -- e.g. // flipping SupportsClasses off must still leave SupportsBooking (old or new) true. var newSupportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : existing.SupportsClasses; var newSupportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : existing.SupportsBooking; if (!newSupportsClasses && !newSupportsBooking) throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true"); existing.SupportsClasses = newSupportsClasses; existing.SupportsBooking = newSupportsBooking; if (data.ContainsKey("description")) existing.Description = data["description"].GetString(); if (data.ContainsKey("defaultCapacity")) { var capacity = data["defaultCapacity"].GetInt32(); if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1"); existing.DefaultCapacity = capacity; } if (data.ContainsKey("numberOfUnits")) { var units = data["numberOfUnits"].GetInt32(); if (units < 1) throw new ArgumentException("numberOfUnits must be at least 1"); existing.NumberOfUnits = units; } if (data.ContainsKey("pricePerHour")) { var price = data["pricePerHour"].GetDecimal(); if (price < 0) throw new ArgumentException("pricePerHour cannot be negative"); existing.PricePerHour = price; } if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean(); var result = await _facilityRepository.UpdateFacility(existing); await _uow.CommitAsync(); var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List { result.FacilityId }); return MapToDto(result, devicesByFacility.GetValueOrDefault(result.FacilityId)); } catch { await _uow.RollbackAsync(); throw; } } public async Task SetFacilityStatus(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var facilityId = Guid.Parse(data["facilityId"].GetString()!); var isActive = data["isActive"].GetBoolean(); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facility = await _facilityRepository.SetFacilityActive(facilityId, clientId, isActive) ?? throw new KeyNotFoundException("Facility not found"); await _uow.CommitAsync(); return MapToDto(facility, null); } catch { await _uow.RollbackAsync(); throw; } } public async Task CreateFacilitySession(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required"); if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required"); if (!data.ContainsKey("endTime")) throw new KeyNotFoundException("endTime is required"); var facilityId = Guid.Parse(data["facilityId"].GetString()!); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facility = await _facilityRepository.GetFacilityById(facilityId); if (facility == null || facility.FKClientId != clientId) throw new KeyNotFoundException("Facility not found"); if (!facility.SupportsClasses) throw new ArgumentException("Sessions can only be created for facilities that support classes"); var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime(); var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime(); if (endTime <= startTime) throw new ArgumentException("endTime must be after startTime"); int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null ? data["capacity"].GetInt32() : null; if (capacity.HasValue && capacity.Value < 1) throw new ArgumentException("capacity must be at least 1 when set"); var session = new FacilitySession { FKFacilityId = facilityId, FKClientId = clientId, StartTime = startTime, EndTime = endTime, Capacity = capacity }; var result = await _facilityRepository.CreateFacilitySession(session); await _uow.CommitAsync(); return MapToDto(result, facility.Name, 0); } catch { await _uow.RollbackAsync(); throw; } } public async Task GetSessions(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null ? Guid.Parse(data["facilityId"].GetString()!) : null; var fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : DateTime.UtcNow.Date; var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : fromUtc.AddDays(7); bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean(); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); // The one read path everything (calendar, class-details occurrence list) already // goes through -- lazy generation here means no background job is needed. await _classService.EnsureOccurrencesGeneratedAsync(clientId); var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc, includeCancelled); var result = new List(); foreach (var session in sessions) { var bookedCount = await _facilityRepository.GetActiveBookingCount(session.FacilitySessionId); result.Add(MapToDto(session, session.Facility?.Name, bookedCount)); } return result; } /// "Mark not held" -- sets IsCancelled/NotHeldReason/NotHeldAt and cascades to /// every Confirmed booking on the occurrence: cancels it and refunds any subscription /// allowance, through the exact same core BookingService.CancelBooking uses (one refund /// code path, not two). The freed time becomes bookable again automatically -- the /// walk-in overlap guard only checks non-cancelled sessions. Same entry point as the old /// bare-IsCancelled CancelFacilitySession this replaces; also reachable via the /// memberships-gated facility-classes/.../not-held route for the Classes tab. public async Task CancelFacilitySession(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); var reason = data.ContainsKey("reason") && data["reason"].ValueKind != JsonValueKind.Null ? data["reason"].GetString() : null; var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var session = await _facilityRepository.MarkFacilitySessionNotHeldAsync(facilitySessionId, clientId, reason) ?? throw new KeyNotFoundException("Facility session not found"); foreach (var booking in session.Bookings.Where(b => b.Status == "Confirmed")) await _bookingService.CancelBookingCore(booking); await _uow.CommitAsync(); return MapToDto(session); } catch { await _uow.RollbackAsync(); throw; } } // --- Device binding (absorbed from PlaceService.AssignDevice, unchanged logic) --- public async Task AssignDevice(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required"); var facilityId = Guid.Parse(data["facilityId"].GetString()!); var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facility = await _facilityRepository.GetFacilityById(facilityId); if (facility == null || facility.FKClientId != clientId) throw new KeyNotFoundException("Facility not found"); var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null; if (!hasDeviceId) { await _facilityRepository.UnassignDeviceAsync(facilityId); } else { var deviceId = Guid.Parse(data["deviceId"].GetString()!); var device = await _facilityRepository.GetDeviceByIdForClientAsync(deviceId, clientId) ?? throw new KeyNotFoundException("Device not found"); if (await _facilityRepository.IsDeviceAssignedToAnotherFacilityAsync(deviceId, facilityId)) throw new ArgumentException("This device is already assigned to another facility."); await _facilityRepository.AssignDeviceAsync(facilityId, deviceId); } await _uow.CommitAsync(); var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List { facilityId }); return MapToDto(facility, devicesByFacility.GetValueOrDefault(facilityId)); } catch { await _uow.RollbackAsync(); throw; } } // --- Availability (new) --- public async Task GetAvailability(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required"); if (!data.ContainsKey("durationMinutes")) throw new KeyNotFoundException("durationMinutes is required"); var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime(); var durationMinutes = data["durationMinutes"].GetInt32(); if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1"); var endTime = startTime.AddMinutes(durationMinutes); Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null ? Guid.Parse(data["facilityId"].GetString()!) : null; var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facilities = facilityId.HasValue ? new List { await _facilityRepository.GetFacilityById(facilityId.Value) is { } f && f.FKClientId == clientId ? f : throw new KeyNotFoundException("Facility not found") } : await _facilityRepository.GetAllFacilities(clientId, includeInactive: false); var result = new List(); foreach (var facility in facilities) { // True interval overlap, not "session starts inside the window" -- a class that // started earlier and is still running must still count. var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facility.FacilityId, startTime, endTime); int? bookingTotal = null, bookingFree = null, classTotal = null, classFree = null; bool classIsUnlimited = false; if (facility.SupportsBooking) { var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facility.FacilityId, startTime, endTime); bookingTotal = facility.NumberOfUnits; // A running class occupies the whole facility, not just a seat. bookingFree = overlappingSessions.Count > 0 ? 0 : Math.Max(0, facility.NumberOfUnits - overlapping); } if (facility.SupportsClasses) { // An unlimited-capacity occurrence has no meaningful total -- Capacity stays // null on it either way, so summing would silently under-count by falling // back to the facility default instead of reporting "no cap" at all. if (overlappingSessions.Any(s => s.IsUnlimitedCapacity)) { classIsUnlimited = true; } else { var totalCapacity = overlappingSessions.Sum(s => s.Capacity ?? facility.DefaultCapacity); var totalBooked = 0; foreach (var s in overlappingSessions) totalBooked += await _facilityRepository.GetActiveBookingCount(s.FacilitySessionId); classTotal = totalCapacity; classFree = Math.Max(0, totalCapacity - totalBooked); } } result.Add(new FacilityAvailabilityDto { FacilityId = facility.FacilityId, FacilityName = facility.Name, SupportsClasses = facility.SupportsClasses, SupportsBooking = facility.SupportsBooking, BookingTotalUnits = bookingTotal, BookingUnitsFree = bookingFree, ClassTotalCapacity = classTotal, ClassUnitsFree = classFree, ClassIsUnlimitedCapacity = classIsUnlimited, PricePerHour = facility.PricePerHour }); } return result; } private static FacilityDto MapToDto(Facility facility, AccessDevice? device) => new() { FacilityId = facility.FacilityId, Name = facility.Name, SupportsClasses = facility.SupportsClasses, SupportsBooking = facility.SupportsBooking, Description = facility.Description, DefaultCapacity = facility.DefaultCapacity, NumberOfUnits = facility.NumberOfUnits, PricePerHour = facility.PricePerHour, IsActive = facility.IsActive, CreatedAt = facility.CreatedAt, DeviceId = device?.DeviceId, DeviceName = device?.DeviceName }; private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new() { FacilitySessionId = session.FacilitySessionId, FacilityId = session.FKFacilityId, FacilityName = facilityName ?? session.Facility?.Name, StartTime = session.StartTime, EndTime = session.EndTime, Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1, IsUnlimitedCapacity = session.IsUnlimitedCapacity, BookedCount = bookedCount, IsCancelled = session.IsCancelled, FacilityClassId = session.FKFacilityClassId, FacilityClassName = session.FacilityClass?.Name, NotHeldReason = session.NotHeldReason, NotHeldAt = session.NotHeldAt }; } }