using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// The mapped result of grafting discovered nodes under an equipment node.
///
/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
/// global parent-before-child ordering across the whole list.
///
/// Variables to ensure under the (post-collapse) folders.
/// Driver FullReference -> equipment NodeId, for live-value routing.
public sealed record DiscoveredInjectionPlan(
IReadOnlyList Folders,
IReadOnlyList Variables,
IReadOnlyDictionary RoutingByRef); // driver FullReference -> equipment NodeId
///
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc
/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
///
public static class DiscoveredNodeMapper
{
///
/// Maps captured into folders + variables (NodeIds scoped under
/// ) plus a driver-FullReference → equipment-NodeId routing map.
///
/// The owning equipment's NodeId (root of the grafted subtree).
/// The captured discovery tree (from CapturingAddressSpaceBuilder).
///
/// Driver FullReferences already authored as Config-DB equipment tags for this driver —
/// skipped so a discovered node never shadows an authored one.
///
/// The folders, variables, and routing map to apply against the OPC UA address space.
public static DiscoveredInjectionPlan Map(
string equipmentId, IReadOnlyList nodes, IReadOnlySet authoredRefs)
{
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
// device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
// FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
// level is retained so identical leaf names across devices don't collide (degrades gracefully).
var collapseIndex1 = kept.Count > 0
&& kept.All(n => n.FolderPathSegments.Count >= 2)
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
static IReadOnlyList Effective(IReadOnlyList segs, bool collapse)
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
var folders = new Dictionary(StringComparer.Ordinal);
var variables = new List();
var routing = new Dictionary(StringComparer.Ordinal);
foreach (var n in kept)
{
var segs = Effective(n.FolderPathSegments, collapseIndex1);
// Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
// parent is the equipment itself).
for (var i = 0; i < segs.Count; i++)
{
var folderPath = string.Join('/', segs.Take(i + 1));
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
if (folders.ContainsKey(nodeId)) continue;
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
}
var varFolderPath = string.Join('/', segs);
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
var varParent = string.IsNullOrEmpty(varFolderPath)
? equipmentId
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
variables.Add(new DiscoveredVariable(
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
routing[n.FullReference] = varNodeId;
}
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
}
///
/// Maps a to the OPC-UA-built-in type STRING that
/// OtOpcUaNodeManager.EnsureVariable's ResolveBuiltInDataType accepts — so a
/// discovered variable resolves to the same built-in type as an authored equipment tag. Most
/// enum names pass through verbatim; /
/// map to the SDK's "Float"/"Double" names, and (a Galaxy
/// attribute reference) is carried as an OPC UA String per the enum's own contract.
///
private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
{
DriverDataType.Boolean => "Boolean",
DriverDataType.Int16 => "Int16",
DriverDataType.Int32 => "Int32",
DriverDataType.Int64 => "Int64",
DriverDataType.UInt16 => "UInt16",
DriverDataType.UInt32 => "UInt32",
DriverDataType.UInt64 => "UInt64",
DriverDataType.Float32 => "Float",
DriverDataType.Float64 => "Double",
DriverDataType.String => "String",
DriverDataType.DateTime => "DateTime",
DriverDataType.Reference => "String",
_ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
};
}