namespace ZB.MOM.WW.OtOpcUa.Admin.Services; /// /// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given /// a plus a snapshot of the draft's UNS tree and its /// equipment + tag counts, returns an the Admin UI shows /// in a confirmation modal before committing the move. /// /// /// Stateless + deterministic — testable without EF or a live draft. The caller /// (Razor page) loads the draft's snapshot via the normal Configuration services, passes /// it in, and the analyzer counts + categorises the impact. The returned /// is the token the caller must re-check at /// confirm time; a mismatch means another operator mutated the draft between preview + /// confirm and the operation needs to be refreshed (decision on concurrent-edit safety /// in Phase 6.4 Scope). /// /// Cross-cluster moves are rejected here (decision #82) — equipment is /// cluster-scoped; the UI disables the drop target and surfaces an Export/Import workflow /// toast instead. /// public static class UnsImpactAnalyzer { /// Run the analyzer. Returns a populated preview or throws for invalid operations. public static UnsImpactPreview Analyze(UnsTreeSnapshot snapshot, UnsMoveOperation move) { ArgumentNullException.ThrowIfNull(snapshot); ArgumentNullException.ThrowIfNull(move); // Cross-cluster guard — the analyzer refuses rather than silently re-homing. if (!string.Equals(move.SourceClusterId, move.TargetClusterId, StringComparison.OrdinalIgnoreCase)) throw new CrossClusterMoveRejectedException( "Equipment is cluster-scoped (decision #82). Use Export → Import to migrate equipment " + "across clusters; drag/drop rejected."); return move.Kind switch { UnsMoveKind.LineMove => AnalyzeLineMove(snapshot, move), UnsMoveKind.AreaRename => AnalyzeAreaRename(snapshot, move), UnsMoveKind.LineMerge => AnalyzeLineMerge(snapshot, move), _ => throw new ArgumentOutOfRangeException(nameof(move), move.Kind, $"Unsupported move kind {move.Kind}"), }; } private static UnsImpactPreview AnalyzeLineMove(UnsTreeSnapshot snapshot, UnsMoveOperation move) { var line = snapshot.FindLine(move.SourceLineId!) ?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found in draft {snapshot.DraftGenerationId}."); var targetArea = snapshot.FindArea(move.TargetAreaId!) ?? throw new UnsMoveValidationException($"Target area '{move.TargetAreaId}' not found in draft {snapshot.DraftGenerationId}."); var warnings = new List(); if (targetArea.LineIds.Contains(line.LineId, StringComparer.OrdinalIgnoreCase)) warnings.Add($"Target area '{targetArea.Name}' already contains line '{line.Name}' — dropping a no-op move."); // If the target area has a line with the same display name as the mover, warn about // visual ambiguity even though the IDs differ (operators frequently reuse line names). if (targetArea.LineIds.Any(lid => snapshot.FindLine(lid) is { } sibling && string.Equals(sibling.Name, line.Name, StringComparison.OrdinalIgnoreCase) && !string.Equals(sibling.LineId, line.LineId, StringComparison.OrdinalIgnoreCase))) { warnings.Add($"Target area '{targetArea.Name}' already has a line named '{line.Name}'. Consider renaming before the move."); } return new UnsImpactPreview { AffectedEquipmentCount = line.EquipmentCount, AffectedTagCount = line.TagCount, CascadeWarnings = warnings, RevisionToken = snapshot.RevisionToken, HumanReadableSummary = $"Moving line '{line.Name}' from area '{snapshot.FindAreaByLineId(line.LineId)?.Name ?? "?"}' " + $"to '{targetArea.Name}' will re-home {line.EquipmentCount} equipment + re-parent {line.TagCount} tags.", }; } private static UnsImpactPreview AnalyzeAreaRename(UnsTreeSnapshot snapshot, UnsMoveOperation move) { var area = snapshot.FindArea(move.SourceAreaId!) ?? throw new UnsMoveValidationException($"Source area '{move.SourceAreaId}' not found in draft {snapshot.DraftGenerationId}."); var affectedEquipment = area.LineIds .Select(lid => snapshot.FindLine(lid)?.EquipmentCount ?? 0) .Sum(); var affectedTags = area.LineIds .Select(lid => snapshot.FindLine(lid)?.TagCount ?? 0) .Sum(); return new UnsImpactPreview { AffectedEquipmentCount = affectedEquipment, AffectedTagCount = affectedTags, CascadeWarnings = [], RevisionToken = snapshot.RevisionToken, HumanReadableSummary = $"Renaming area '{area.Name}' → '{move.NewName}' cascades to {area.LineIds.Count} lines / " + $"{affectedEquipment} equipment / {affectedTags} tags.", }; } private static UnsImpactPreview AnalyzeLineMerge(UnsTreeSnapshot snapshot, UnsMoveOperation move) { var src = snapshot.FindLine(move.SourceLineId!) ?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found."); var dst = snapshot.FindLine(move.TargetLineId!) ?? throw new UnsMoveValidationException($"Target line '{move.TargetLineId}' not found."); var warnings = new List(); if (!string.Equals(snapshot.FindAreaByLineId(src.LineId)?.AreaId, snapshot.FindAreaByLineId(dst.LineId)?.AreaId, StringComparison.OrdinalIgnoreCase)) { warnings.Add($"Lines '{src.Name}' and '{dst.Name}' are in different areas. The merge will re-parent equipment + tags into '{dst.Name}'s area."); } return new UnsImpactPreview { AffectedEquipmentCount = src.EquipmentCount, AffectedTagCount = src.TagCount, CascadeWarnings = warnings, RevisionToken = snapshot.RevisionToken, HumanReadableSummary = $"Merging line '{src.Name}' into '{dst.Name}': {src.EquipmentCount} equipment + {src.TagCount} tags re-parent. " + $"The source line is deleted at commit.", }; } } /// Kind of UNS structural move the analyzer understands. public enum UnsMoveKind { /// Drag a whole line from one area to another. LineMove, /// Rename an area (cascades to the UNS paths of every equipment + tag below it). AreaRename, /// Merge two lines into one; source line's equipment + tags are re-parented. LineMerge, } /// One UNS structural move request. /// Move variant — selects which source + target fields are required. /// Cluster of the source node. Must match (decision #82). /// Cluster of the target node. /// Source area id for . /// Source line id for / . /// Target area id for . /// Target line id for . /// New display name for . public sealed record UnsMoveOperation( UnsMoveKind Kind, string SourceClusterId, string TargetClusterId, string? SourceAreaId = null, string? SourceLineId = null, string? TargetAreaId = null, string? TargetLineId = null, string? NewName = null); /// Snapshot of the UNS tree + counts the analyzer walks. public sealed class UnsTreeSnapshot { public required long DraftGenerationId { get; init; } public required DraftRevisionToken RevisionToken { get; init; } public required IReadOnlyList Areas { get; init; } public required IReadOnlyList Lines { get; init; } public UnsAreaSummary? FindArea(string areaId) => Areas.FirstOrDefault(a => string.Equals(a.AreaId, areaId, StringComparison.OrdinalIgnoreCase)); public UnsLineSummary? FindLine(string lineId) => Lines.FirstOrDefault(l => string.Equals(l.LineId, lineId, StringComparison.OrdinalIgnoreCase)); public UnsAreaSummary? FindAreaByLineId(string lineId) => Areas.FirstOrDefault(a => a.LineIds.Contains(lineId, StringComparer.OrdinalIgnoreCase)); } public sealed record UnsAreaSummary(string AreaId, string Name, IReadOnlyList LineIds); public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount); /// /// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it /// in the . Confirm compares the token against /// the draft's live value; mismatch means another operator mutated the draft between /// preview + commit — raise 409 Conflict / refresh-required in the UI. /// public sealed record DraftRevisionToken(string Value) { /// Compare two tokens for equality; null-safe. public bool Matches(DraftRevisionToken? other) => other is not null && string.Equals(Value, other.Value, StringComparison.Ordinal); } /// Output of . public sealed class UnsImpactPreview { public required int AffectedEquipmentCount { get; init; } public required int AffectedTagCount { get; init; } public required IReadOnlyList CascadeWarnings { get; init; } public required DraftRevisionToken RevisionToken { get; init; } public required string HumanReadableSummary { get; init; } } /// Thrown when a move targets a different cluster than the source (decision #82). public sealed class CrossClusterMoveRejectedException(string message) : Exception(message); /// Thrown when the move operation references a source / target that doesn't exist in the draft. public sealed class UnsMoveValidationException(string message) : Exception(message);