using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
///
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
/// from the Config DB's
/// UnsArea / UnsLine / Equipment / Tag rows. Runs during
/// address-space build per whose
/// Namespace.Kind = Equipment; SystemPlatform-kind namespaces (Galaxy) are
/// exempt per decision #120 and reach this walker only indirectly through
/// .
///
///
///
/// Composition strategy. ADR-001 (2026-04-20) accepted Option A — Config
/// primary. The walker treats the supplied
/// snapshot as the authoritative published surface. Every Equipment row becomes a
/// folder node at the UNS level-5 segment; every bound to an
/// Equipment (non-null ) becomes a variable node under
/// it. Driver-discovered tags that have no Config-DB row are not added by this
/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
///
///
///
/// Under each Equipment node. Five identifier properties per decision #121
/// (EquipmentId, EquipmentUuid, MachineCode, ZTag,
/// SAPID) are added as OPC UA properties — external systems (ERP, SAP PM)
/// resolve equipment by whichever identifier they natively use without a sidecar.
/// materializes the OPC 40010
/// Identification sub-folder with the nine decision-#139 fields when at least one
/// is non-null; when all nine are null the sub-folder is omitted rather than
/// appearing empty.
///
///
///
/// Address resolution. Variable nodes carry the driver-side full reference
/// in copied from Tag.TagConfig
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
/// runtime the dispatch layer routes Read/Write calls through the configured
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
/// UI + OPC UA client inspection of node status.
///
///
///
/// Pure function. This class has no dependency on the OPC UA SDK, no
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
/// into the supplied . The server-side wiring
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
/// PR alongside NodeScopeResolver's Config-DB join.
///
///
public static class EquipmentNodeWalker
{
///
/// Walk into .
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
/// Area → Line → Equipment folders under it, then identifier properties + the
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
///
///
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
/// creating this (e.g. rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)).
///
/// Pre-loaded + pre-filtered rows for a single published generation.
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
{
ArgumentNullException.ThrowIfNull(namespaceBuilder);
ArgumentNullException.ThrowIfNull(content);
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
// O(N·M) re-scan at each UNS level on large fleets.
var linesByArea = content.Lines
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var equipmentByLine = content.Equipment
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var tagsByEquipment = content.Tags
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
.Where(a => a.Enabled)
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
{
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;
foreach (var line in areaLines)
{
var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;
foreach (var equipment in lineEquipment)
{
var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
foreach (var alarm in alarms)
AddScriptedAlarmVariable(equipmentBuilder, alarm);
}
}
}
}
///
/// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
/// MachineCode is required per ; ZTag + SAPID are nullable in
/// the data model so they're skipped when null to avoid empty-string noise in the
/// browse tree.
///
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
{
equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
if (!string.IsNullOrEmpty(equipment.ZTag))
equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
if (!string.IsNullOrEmpty(equipment.SAPID))
equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
}
///
/// Emit a single Tag row as an . The driver
/// full reference lives in Tag.TagConfig (wire-level address, driver-specific
/// JSON blob); the variable node's data type derives from Tag.DataType.
/// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
///
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
{
var attr = new DriverAttributeInfo(
FullName: tag.TagConfig,
DriverDataType: ParseDriverDataType(tag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false);
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
}
///
/// Parse (stored as the enum
/// name string, decision #138) into the enum value. Unknown names fall back to
/// so a one-off driver-specific type doesn't
/// abort the whole walk; the underlying driver still sees the original TagConfig
/// address + can surface its own typed value via the OPC UA variant at read time.
///
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
///
/// Emit a row as a
/// variable node. FullName doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The VirtualTagId discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
///
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
FullName: vtag.VirtualTagId,
DriverDataType: ParseDriverDataType(vtag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: vtag.Historize,
IsAlarm: false,
WriteIdempotent: false,
Source: NodeSourceKind.Virtual,
VirtualTagId: vtag.VirtualTagId,
ScriptedAlarmId: null);
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
///
/// Emit a row as a
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete AlarmConditionState subclass
/// per ); this walker provides the browse-level
/// anchor + the flag that triggers that
/// materialization path.
///
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
FullName: alarm.ScriptedAlarmId,
DriverDataType: DriverDataType.Boolean,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false,
IsAlarm: true,
WriteIdempotent: false,
Source: NodeSourceKind.ScriptedAlarm,
VirtualTagId: null,
ScriptedAlarmId: alarm.ScriptedAlarmId);
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
}
}
///
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
/// DB rows. All four collections are scoped to the same
/// + the same
/// row. The walker assumes this filter
/// was applied by the caller + does no cross-generation or cross-namespace validation.
///
public sealed record EquipmentNamespaceContent(
IReadOnlyList Areas,
IReadOnlyList Lines,
IReadOnlyList Equipment,
IReadOnlyList Tags,
IReadOnlyList? VirtualTags = null,
IReadOnlyList? ScriptedAlarms = null);