using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; public class TemplateFolderService { private readonly ITemplateEngineRepository _repository; private readonly IAuditService _auditService; /// /// Initializes the service with its required dependencies. /// /// Template engine repository for folder persistence. /// Audit service for logging folder operations. public TemplateFolderService(ITemplateEngineRepository repository, IAuditService auditService) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); } /// /// Creates a new template folder, enforcing name uniqueness within the parent. /// /// Display name for the new folder. /// Parent folder id, or null to create at root level. /// Username of the actor performing the operation (for audit). /// Cancellation token. /// A task that resolves to the created , or a failure result if validation fails. public async Task> CreateFolderAsync( string name, int? parentFolderId, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) return Result.Failure("Folder name is required."); if (parentFolderId.HasValue) { var parent = await _repository.GetFolderByIdAsync(parentFolderId.Value, cancellationToken); if (parent == null) return Result.Failure($"Parent folder with ID {parentFolderId.Value} not found."); } var all = await _repository.GetAllFoldersAsync(cancellationToken); if (all.Any(f => f.ParentFolderId == parentFolderId && string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase))) return Result.Failure($"A folder named '{name}' already exists at this level."); var siblings = all.Where(f => f.ParentFolderId == parentFolderId).ToList(); var sortOrder = siblings.Count == 0 ? 0 : siblings.Max(f => f.SortOrder) + 1; var folder = new TemplateFolder(name) { ParentFolderId = parentFolderId, SortOrder = sortOrder }; await _repository.AddFolderAsync(folder, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Create", "TemplateFolder", folder.Id.ToString(), name, folder, cancellationToken); // The audit entry is staged on the change tracker by LogAsync and needs its // own SaveChangesAsync to persist (mirrors TemplateService) — otherwise the // row is discarded when the ManagementActor's DI scope is disposed. await _repository.SaveChangesAsync(cancellationToken); return Result.Success(folder); } /// /// Renames an existing template folder, enforcing name uniqueness within its parent. /// /// Id of the folder to rename. /// New display name for the folder. /// Username of the actor performing the operation (for audit). /// Cancellation token. /// A task that resolves to the updated , or a failure result if the folder is not found or the name collides. public async Task> RenameFolderAsync( int folderId, string newName, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(newName)) return Result.Failure("Folder name is required."); var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); if (folder == null) return Result.Failure($"Folder with ID {folderId} not found."); var all = await _repository.GetAllFoldersAsync(cancellationToken); if (all.Any(f => f.Id != folderId && f.ParentFolderId == folder.ParentFolderId && string.Equals(f.Name, newName, StringComparison.OrdinalIgnoreCase))) return Result.Failure($"A folder named '{newName}' already exists at this level."); folder.Name = newName; await _repository.UpdateFolderAsync(folder, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Update", "TemplateFolder", folder.Id.ToString(), newName, folder, cancellationToken); // Persist the staged audit entry (see CreateFolderAsync). await _repository.SaveChangesAsync(cancellationToken); return Result.Success(folder); } /// /// Moves an existing template folder to a new parent, checking for cycles and name collisions. /// /// Id of the folder to move. /// Target parent folder id, or null to move to root level. /// Username of the actor performing the operation (for audit). /// Cancellation token. /// A task that resolves to the moved , or a failure result if a cycle or name collision is detected. public async Task> MoveFolderAsync( int folderId, int? newParentId, string user, CancellationToken cancellationToken = default) { var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); if (folder == null) return Result.Failure($"Folder with ID {folderId} not found."); if (newParentId.HasValue) { if (newParentId.Value == folderId) return Result.Failure("Cannot move a folder into itself (cycle)."); var newParent = await _repository.GetFolderByIdAsync(newParentId.Value, cancellationToken); if (newParent == null) return Result.Failure($"Target folder with ID {newParentId.Value} not found."); var all = await _repository.GetAllFoldersAsync(cancellationToken); var byId = all.ToDictionary(f => f.Id); var cursor = newParentId; // Walk up from newParentId — if we encounter folderId, the move would create a cycle. // Bound iterations by byId.Count to defensively terminate on a malformed graph. var iterations = 0; while (cursor.HasValue) { if (cursor.Value == folderId) return Result.Failure("Cannot move a folder under one of its descendants (cycle)."); if (++iterations > byId.Count) return Result.Failure("Folder hierarchy contains a cycle; cannot determine ancestry."); cursor = byId.TryGetValue(cursor.Value, out var node) ? node.ParentFolderId : null; } // Sibling-name uniqueness in destination. if (all.Any(f => f.Id != folderId && f.ParentFolderId == newParentId && string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase))) return Result.Failure($"A folder named '{folder.Name}' already exists in the target folder."); } else { var all = await _repository.GetAllFoldersAsync(cancellationToken); if (all.Any(f => f.Id != folderId && f.ParentFolderId == null && string.Equals(f.Name, folder.Name, StringComparison.OrdinalIgnoreCase))) return Result.Failure($"A folder named '{folder.Name}' already exists at the root."); } folder.ParentFolderId = newParentId; await _repository.UpdateFolderAsync(folder, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Move", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken); // Persist the staged audit entry (see CreateFolderAsync). await _repository.SaveChangesAsync(cancellationToken); return Result.Success(folder); } /// /// Reorders a template folder relative to its siblings under the same parent by /// swapping with the adjacent sibling in the /// requested direction. Siblings are ordered ascending by /// then ; swaps with the /// previous sibling and with the next. Reordering past /// either end is a no-op (returns success without persisting or auditing). /// /// Id of the folder to reorder. /// Direction to move the folder within its sibling set. /// Username of the actor performing the operation (for audit). /// Cancellation token. /// A task that resolves to the reordered , or a failure result if the folder is not found. public async Task> ReorderFolderAsync( int folderId, ReorderDirection direction, string user, CancellationToken cancellationToken = default) { var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); if (folder == null) return Result.Failure($"Folder with ID {folderId} not found."); var all = await _repository.GetAllFoldersAsync(cancellationToken); // Deterministic sibling ordering: ascending SortOrder, then Name as tie-breaker. var siblings = all .Where(f => f.ParentFolderId == folder.ParentFolderId) .OrderBy(f => f.SortOrder) .ThenBy(f => f.Name, StringComparer.OrdinalIgnoreCase) .ToList(); var index = siblings.FindIndex(f => f.Id == folderId); var adjacentIndex = direction == ReorderDirection.Up ? index - 1 : index + 1; // At the end of the sibling set in the requested direction: no-op. if (adjacentIndex < 0 || adjacentIndex >= siblings.Count) return Result.Success(folder); var adjacent = siblings[adjacentIndex]; // Swap sort order with the adjacent sibling. (folder.SortOrder, adjacent.SortOrder) = (adjacent.SortOrder, folder.SortOrder); await _repository.UpdateFolderAsync(folder, cancellationToken); await _repository.UpdateFolderAsync(adjacent, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Reorder", "TemplateFolder", folder.Id.ToString(), folder.Name, folder, cancellationToken); // Persist the staged audit entry (see CreateFolderAsync). await _repository.SaveChangesAsync(cancellationToken); return Result.Success(folder); } /// /// Deletes an empty template folder. Fails if the folder contains sub-folders or templates. /// /// Id of the folder to delete. /// Username of the actor performing the operation (for audit). /// Cancellation token. /// A task that resolves to a success result if the folder was deleted, or a failure result if the folder is not found or not empty. public async Task> DeleteFolderAsync( int folderId, string user, CancellationToken cancellationToken = default) { var folder = await _repository.GetFolderByIdAsync(folderId, cancellationToken); if (folder == null) return Result.Failure($"Folder with ID {folderId} not found."); var allFolders = await _repository.GetAllFoldersAsync(cancellationToken); // Only FolderId is read — the untracked identity projection, not the full // template graph with every script body (review 2026-10-21 04/P6, 08/P1). var allTemplates = await _repository.GetTemplateStubsAsync(cancellationToken); var childFolderCount = allFolders.Count(f => f.ParentFolderId == folderId); var childTemplateCount = allTemplates.Count(t => t.FolderId == folderId); if (childFolderCount > 0 || childTemplateCount > 0) { var parts = new List(); if (childTemplateCount > 0) parts.Add($"{childTemplateCount} template{(childTemplateCount == 1 ? "" : "s")}"); if (childFolderCount > 0) parts.Add($"{childFolderCount} subfolder{(childFolderCount == 1 ? "" : "s")}"); return Result.Failure( $"Cannot delete folder '{folder.Name}': it contains {string.Join(" and ", parts)}. " + "Move or delete contents first."); } await _repository.DeleteFolderAsync(folderId, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Delete", "TemplateFolder", folderId.ToString(), folder.Name, null, cancellationToken); // Persist the staged audit entry (see CreateFolderAsync). await _repository.SaveChangesAsync(cancellationToken); return Result.Success(true); } }