using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
///
/// Builds the path index consumed by
/// from a Config-DB snapshot of a single published generation. Runs once per generation
/// (or on every generation change) at the Server bootstrap layer; the produced index is
/// immutable + hot-path readable per ADR-001 Task B.
///
///
/// The index key is the driver-side full reference (Tag.TagConfig) — the same
/// string the dispatch layer passes to . The value
/// is a with every UNS level populated:
/// ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId. Tag rows
/// with null EquipmentId (SystemPlatform-namespace Galaxy tags per decision #120)
/// are excluded from the index — the cluster-only fallback path in the resolver handles
/// them without needing an index entry.
///
/// Duplicate keys are not expected but would be indicative of corrupt data — the
/// builder throws on collision so a config drift
/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.
///
public static class ScopePathIndexBuilder
{
///
/// Build a fullReference → NodeScope index from the four Config-DB collections for a
/// single namespace. Callers must filter inputs to a single
/// + the same upstream.
///
/// Owning cluster — populates .
/// Owning namespace — populates .
/// Pre-loaded rows for the namespace.
public static IReadOnlyDictionary Build(
string clusterId,
string namespaceId,
EquipmentNamespaceContent content)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
ArgumentNullException.ThrowIfNull(content);
var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
var index = new Dictionary(StringComparer.Ordinal);
foreach (var tag in content.Tags)
{
// Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
// cluster-only resolver fallback handles those without needing an index entry.
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
// Broken FK — Tag references a missing Equipment row. Skip rather than crash;
// sp_ValidateDraft should have caught this at publish, so any drift here is
// unexpected but non-fatal.
if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
var scope = new NodeScope
{
ClusterId = clusterId,
NamespaceId = namespaceId,
UnsAreaId = areaId,
UnsLineId = lineId,
EquipmentId = tag.EquipmentId,
TagId = tag.TagConfig,
Kind = NodeHierarchyKind.Equipment,
};
if (!index.TryAdd(tag.TagConfig, scope))
throw new InvalidOperationException(
$"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
"Config data is corrupt — two Tag rows produced the same wire-level address.");
}
return index;
}
}