using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; /// /// Hierarchical area management per site. /// - CRUD for areas with parent-child relationships /// - Deletion constrained if instances are assigned /// - Audit logging /// public class AreaService { private readonly ITemplateEngineRepository _repository; private readonly IAuditService _auditService; /// /// Initializes a new instance of the AreaService with the specified repository and audit service. /// /// The template engine repository for data access. /// The audit service for logging area changes. public AreaService(ITemplateEngineRepository repository, IAuditService auditService) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); } /// /// Creates a new area within a site. /// /// The name of the new area. /// The site identifier. /// Optional parent area identifier for hierarchical organization. /// The user performing the action for audit logging. /// Cancellation token. public async Task> CreateAreaAsync( string name, int siteId, int? parentAreaId, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) return Result.Failure("Area name is required."); // Validate parent area if specified if (parentAreaId.HasValue) { var parent = await _repository.GetAreaByIdAsync(parentAreaId.Value, cancellationToken); if (parent == null) return Result.Failure($"Parent area with ID {parentAreaId.Value} not found."); if (parent.SiteId != siteId) return Result.Failure($"Parent area with ID {parentAreaId.Value} does not belong to site {siteId}."); } // Check for duplicate name at the same level var siblings = await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken); var duplicate = siblings.FirstOrDefault(a => a.ParentAreaId == parentAreaId && string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); if (duplicate != null) return Result.Failure($"An area named '{name}' already exists at this level in site {siteId}."); var area = new Area(name) { SiteId = siteId, ParentAreaId = parentAreaId }; await _repository.AddAreaAsync(area, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Create", "Area", area.Id.ToString(), name, area, cancellationToken); return Result.Success(area); } /// /// Updates an area's name. /// /// The area identifier. /// The new name for the area. /// The user performing the action for audit logging. /// Cancellation token. public async Task> UpdateAreaAsync( int areaId, string name, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) return Result.Failure("Area name is required."); var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken); if (area == null) return Result.Failure($"Area with ID {areaId} not found."); // Check for duplicate name at the same level var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken); var duplicate = siblings.FirstOrDefault(a => a.Id != areaId && a.ParentAreaId == area.ParentAreaId && string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); if (duplicate != null) return Result.Failure($"An area named '{name}' already exists at this level."); area.Name = name; await _repository.UpdateAreaAsync(area, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Update", "Area", area.Id.ToString(), name, area, cancellationToken); return Result.Success(area); } /// /// Re-parents an area within its site. `newParentAreaId == null` moves the area to the site root. /// Rejects: self-parent, descendant-parent (cycle), cross-site parent, name collision at new level. /// /// The area identifier. /// The new parent area identifier, or null to move to site root. /// The user performing the action for audit logging. /// Cancellation token. public async Task> MoveAreaAsync( int areaId, int? newParentAreaId, string user, CancellationToken cancellationToken = default) { var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken); if (area == null) return Result.Failure($"Area with ID {areaId} not found."); if (newParentAreaId == areaId) return Result.Failure("An area cannot be its own parent."); if (newParentAreaId.HasValue) { var newParent = await _repository.GetAreaByIdAsync(newParentAreaId.Value, cancellationToken); if (newParent == null) return Result.Failure($"Target parent area with ID {newParentAreaId.Value} not found."); if (newParent.SiteId != area.SiteId) return Result.Failure("Areas can only be moved within the same site."); } var siblings = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken); // Cycle prevention: the new parent must not be a descendant of the area being moved. if (newParentAreaId.HasValue) { var descendants = GetDescendantAreaIds(areaId, siblings); if (descendants.Contains(newParentAreaId.Value)) return Result.Failure( $"Cannot move area '{area.Name}' under one of its own descendants."); } if (newParentAreaId == area.ParentAreaId) return Result.Success(area); var collision = siblings.FirstOrDefault(a => a.Id != areaId && a.ParentAreaId == newParentAreaId && string.Equals(a.Name, area.Name, StringComparison.OrdinalIgnoreCase)); if (collision != null) return Result.Failure( $"An area named '{area.Name}' already exists at the target level."); area.ParentAreaId = newParentAreaId; await _repository.UpdateAreaAsync(area, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Move", "Area", area.Id.ToString(), area.Name, area, cancellationToken); return Result.Success(area); } /// /// Deletes an area. Blocked if instances are assigned to this area or any descendant area. /// /// The area identifier. /// The user performing the action for audit logging. /// Cancellation token. public async Task> DeleteAreaAsync( int areaId, string user, CancellationToken cancellationToken = default) { var area = await _repository.GetAreaByIdAsync(areaId, cancellationToken); if (area == null) return Result.Failure($"Area with ID {areaId} not found."); // Check for instances assigned to this area var allInstances = await _repository.GetInstancesBySiteIdAsync(area.SiteId, cancellationToken); var allAreas = await _repository.GetAreasBySiteIdAsync(area.SiteId, cancellationToken); // Collect this area and all descendant area IDs var descendantIds = GetDescendantAreaIds(areaId, allAreas); descendantIds.Add(areaId); var assignedInstances = allInstances .Where(i => i.AreaId.HasValue && descendantIds.Contains(i.AreaId.Value)) .ToList(); if (assignedInstances.Count > 0) { var names = string.Join(", ", assignedInstances.Select(i => i.UniqueName).Take(5)); return Result.Failure( $"Cannot delete area '{area.Name}': {assignedInstances.Count} instance(s) are assigned to it or its sub-areas ({names}{(assignedInstances.Count > 5 ? "..." : "")})."); } // Check for child areas (must delete children first, or we delete recursively) var childAreas = allAreas.Where(a => a.ParentAreaId == areaId).ToList(); if (childAreas.Count > 0) { var childNames = string.Join(", ", childAreas.Select(a => a.Name)); return Result.Failure( $"Cannot delete area '{area.Name}': it has child areas ({childNames}). Delete child areas first."); } await _repository.DeleteAreaAsync(areaId, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Delete", "Area", areaId.ToString(), area.Name, null, cancellationToken); return Result.Success(true); } /// /// Gets all areas for a site. /// /// The site identifier. /// Cancellation token. public async Task> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) => await _repository.GetAreasBySiteIdAsync(siteId, cancellationToken); /// /// Gets a single area by ID. /// /// The area identifier. /// Cancellation token. public async Task GetAreaByIdAsync(int areaId, CancellationToken cancellationToken = default) => await _repository.GetAreaByIdAsync(areaId, cancellationToken); private static HashSet GetDescendantAreaIds(int parentId, IReadOnlyList allAreas) { var result = new HashSet(); var queue = new Queue(); queue.Enqueue(parentId); while (queue.Count > 0) { var current = queue.Dequeue(); foreach (var child in allAreas.Where(a => a.ParentAreaId == current)) { if (result.Add(child.Id)) queue.Enqueue(child.Id); } } return result; } }