using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class ServicesRegistryService : IServicesRegistryService { // Product contract: the registry only accepts the three work-order types // the product currently supports. Any other WorkOrderType is rejected. public static readonly IReadOnlySet AllowedWorkOrderTypes = new HashSet { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }; private static readonly WorkOrderType[] DefaultSupportedWorkOrderTypes = { WorkOrderType.PM, WorkOrderType.Reactive, WorkOrderType.Emergency }; public const string SchedulerRole = "Scheduler"; public const string AdminRole = "Admin"; private const int NameMaxLength = 200; private const int IconKeyMaxLength = 64; private readonly IServicesRegistryDataService _dataService; public ServicesRegistryService(IServicesRegistryDataService dataService) { _dataService = dataService; } public async Task> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken) { var catalogType = WorkOrderCatalogType.For(workOrderType); ValidateWorkOrderTypeFilter(catalogType); var rows = await _dataService.GetServiceRowsAsync(isActive, catalogType, cancellationToken); return rows.Select(MapToDto).ToList(); } public async Task GetByIdAsync(int id, CancellationToken cancellationToken) { var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); return row == null ? null : MapToDto(row); } public async Task CreateAsync(ClaimsPrincipal user, ServiceInput input, CancellationToken cancellationToken) { EnsureCanMutate(user); var name = RequireValidName(input.Name); var trade = RequireCanonicalTrade(input.Trade); var iconKey = RequireValidIconKey(input.IconKey); var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes); var template = await ResolveTemplateLinkAsync(input, cancellationToken); if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), null, cancellationToken)) throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."); var service = new Service { Name = name, NormalizedName = TradeCatalog.Normalize(name), Trade = trade, IconKey = iconKey, RequiresCompletionDocument = input.RequiresCompletionDocument, CompletionDocTemplateId = template?.Id, IsActive = true, SupportedWorkOrderTypes = supportedTypes .Select(t => new ServiceWorkOrderType { WorkOrderType = t }) .ToList() }; try { await _dataService.AddServiceAsync(service, cancellationToken); } catch (ServicesRegistryDuplicateNameException) { throw DuplicateName(); } var row = await _dataService.GetServiceRowByIdAsync(service.Id, cancellationToken); return MapToDto(row!); } public async Task UpdateAsync(ClaimsPrincipal user, int id, ServiceInput input, CancellationToken cancellationToken) { EnsureCanMutate(user); var service = await _dataService.GetServiceForUpdateAsync(id, cancellationToken); if (service == null || service.IsDeleted == true) throw new ServicesRegistryValidationException("NotFound", "Service not found."); var name = RequireValidName(input.Name); var trade = RequireCanonicalTrade(input.Trade); var iconKey = RequireValidIconKey(input.IconKey); var supportedTypes = RequireValidSupportedTypes(input.SupportedWorkOrderTypes); var template = await ResolveTemplateLinkAsync(input, cancellationToken); if (await _dataService.NormalizedNameExistsAsync(TradeCatalog.Normalize(name), id, cancellationToken)) throw new ServicesRegistryValidationException("DuplicateName", "A service with this name already exists."); service.Name = name; service.NormalizedName = TradeCatalog.Normalize(name); service.Trade = trade; service.IconKey = iconKey; service.RequiresCompletionDocument = input.RequiresCompletionDocument; service.CompletionDocTemplateId = template?.Id; if (input.IsActive.HasValue) { EnsureCanChangeActiveState(user); service.IsActive = input.IsActive.Value; } try { await _dataService.SaveServiceAsync(service, supportedTypes, cancellationToken); } catch (ServicesRegistryDuplicateNameException) { throw DuplicateName(); } var row = await _dataService.GetServiceRowByIdAsync(id, cancellationToken); return MapToDto(row!); } public async Task DeactivateAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken) { if (user is null || !user.IsInRole(AdminRole)) throw new ServicesRegistryValidationException("Forbidden", "Only administrators can deactivate services."); var deactivated = await _dataService.DeactivateServiceAsync(id, cancellationToken); if (!deactivated) throw new ServicesRegistryValidationException("NotFound", "Service not found."); } private static void EnsureCanMutate(ClaimsPrincipal user) { if (user is null || !(user.IsInRole(SchedulerRole) || user.IsInRole(AdminRole))) throw new ServicesRegistryValidationException("Forbidden", "Scheduler or Admin role is required."); } private static void EnsureCanChangeActiveState(ClaimsPrincipal user) { if (user is null || !user.IsInRole(AdminRole)) throw new ServicesRegistryValidationException("Forbidden", "Only administrators can change service active state."); } private static ServicesRegistryValidationException DuplicateName() => new("DuplicateName", "A service with this name already exists."); private static string RequireValidName(string? name) { if (string.IsNullOrWhiteSpace(name)) throw new ServicesRegistryValidationException("NameRequired", "Service name is required."); var trimmed = name.Trim(); if (trimmed.Length > NameMaxLength) throw new ServicesRegistryValidationException("NameInvalid", $"Service name cannot exceed {NameMaxLength} characters."); return trimmed; } private static string RequireCanonicalTrade(string? trade) { if (string.IsNullOrWhiteSpace(trade)) throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values."); var normalized = TradeCatalog.Normalize(trade); var canonical = TradeCatalog.CanonicalTrades .FirstOrDefault(t => TradeCatalog.Normalize(t) == normalized); if (canonical == null) throw new ServicesRegistryValidationException("TradeInvalid", "Trade must be one of the canonical trade values."); return canonical; } private static string RequireValidIconKey(string? iconKey) { if (string.IsNullOrWhiteSpace(iconKey)) throw new ServicesRegistryValidationException("IconKeyInvalid", "Icon key is required."); var trimmed = iconKey.Trim(); if (trimmed.Length > IconKeyMaxLength) throw new ServicesRegistryValidationException("IconKeyInvalid", $"Icon key cannot exceed {IconKeyMaxLength} characters."); return trimmed; } private static List RequireValidSupportedTypes(IEnumerable? supportedTypes) { // The registry form does not expose type categories yet. Until it // does, an omitted or empty category list keeps a service available // to the three supported work-order types. var types = supportedTypes?.Distinct().ToList(); if (types == null || types.Count == 0) return DefaultSupportedWorkOrderTypes.ToList(); if (types.Any(t => !AllowedWorkOrderTypes.Contains(t))) throw new ServicesRegistryValidationException( "WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency."); return types; } private static void ValidateWorkOrderTypeFilter(WorkOrderType? workOrderType) { if (workOrderType.HasValue && !AllowedWorkOrderTypes.Contains(workOrderType.Value)) throw new ServicesRegistryValidationException( "WorkOrderTypeInvalid", "Supported work order types may only include PM, Reactive, or Emergency."); } private async Task ResolveTemplateLinkAsync(ServiceInput input, CancellationToken cancellationToken) { // Toggle off clears/ignores any template link. if (!input.RequiresCompletionDocument || input.CompletionDocTemplateId is not int templateId) return null; var template = await _dataService.GetActiveTemplateSummaryAsync(templateId, cancellationToken); if (template == null) throw new ServicesRegistryValidationException( "TemplateInvalid", "Completion document template must reference an active, non-deleted template."); return template; } private static ServiceDto MapToDto(ServiceRegistryRow row) { return new ServiceDto { Id = row.Id, Name = row.Name, Trade = row.Trade, IconKey = row.IconKey, RequiresCompletionDocument = row.RequiresCompletionDocument, CompletionDocTemplate = row.CompletionDocTemplateId == null ? null : new LinkedTemplateSummaryDto { Id = row.CompletionDocTemplateId.Value, Name = row.CompletionDocTemplateName ?? "" }, IsActive = row.IsActive, SupportedWorkOrderTypes = row.SupportedWorkOrderTypes.OrderBy(t => (int)t).ToList() }; } } }