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);
}
}