using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>Outcome of <see cref="AddressSpaceComposer.Compose"/> — pure value tuple, no side effects.
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
public sealed record AddressSpaceComposition(
    IReadOnlyList<UnsAreaProjection> UnsAreas,
    IReadOnlyList<UnsLineProjection> UnsLines,
    IReadOnlyList<EquipmentNode> EquipmentNodes,
    IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
    IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
{
    /// <summary>Convenience constructor for tests + earlier callers that don't carry UNS data.</summary>
    /// <param name="equipmentNodes">The equipment nodes.</param>
    /// <param name="driverInstancePlans">The driver instance plans.</param>
    /// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
    public AddressSpaceComposition(
        IReadOnlyList<EquipmentNode> equipmentNodes,
        IReadOnlyList<DriverInstancePlan> driverInstancePlans,
        IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
        : this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
               equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
    {
    }

    /// <summary>
    ///     Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
    ///     in an <c>Equipment</c>-kind namespace. <c>AddressSpaceApplier.MaterialiseEquipmentTags</c>
    ///     materialises each as a Variable under its existing equipment folder. Declared as an
    ///     init-only member defaulting to empty (rather than a positional parameter) so every existing
    ///     convenience constructor + call site keeps compiling unchanged; new producers set it via
    ///     initializer.
    /// </summary>
    public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();

    /// <summary>Equipment-namespace VirtualTags. See <see cref="EquipmentVirtualTagPlan"/>. Init-only,
    /// defaults empty so every existing constructor + call site keeps compiling.</summary>
    public IReadOnlyList<EquipmentVirtualTagPlan> EquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();

    /// <summary>
    ///     Per-equipment scripted-alarm host plans — the richer analogue of the thin
    ///     <see cref="ScriptedAlarmPlans"/> projection. Each carries the fully-resolved predicate
    ///     source (joined from its <see cref="Script"/>) and the merged dependency graph (predicate
    ///     <c>ctx.GetTag("…")</c> reads UNION <c>{TagPath}</c> tokens in the message template) so the
    ///     runtime alarm host can subscribe to every signal and evaluate the predicate. See
    ///     <see cref="EquipmentScriptedAlarmPlan"/>. Init-only, defaults empty so every existing
    ///     constructor + call site keeps compiling unchanged.
    /// </summary>
    public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
}

public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);

/// <summary>One UNS level-5 equipment folder in the address space. <see cref="EquipmentId"/> is the
/// logical NodeId; <see cref="DisplayName"/> is the friendly UNS Name segment; <see cref="UnsLineId"/>
/// is the parent line the folder hangs under.
/// <para><see cref="DriverInstanceId"/> / <see cref="DeviceId"/> carry the equipment's optional bindings
/// (both <c>null</c> ⇒ driver-less / no device), copied straight from the <c>Equipment</c> row.
/// <see cref="DeviceHost"/> is the device's connection host (e.g. <c>"10.0.0.5:8193"</c>) resolved from the
/// bound <c>Device</c>'s schemaless <c>DeviceConfig</c> JSON via
/// <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent.TryExtractHost"/> — <c>null</c> when there
/// is no device, no <c>HostAddress</c> in its config, or the host cannot be parsed. These three let a later
/// task graft a driver's discovered FixedTree onto an equipment that has zero authored tags, and partition a
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
/// the artifact-decode sides (single source of truth: <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.DeviceConfigIntent"/>);
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
/// (trim + lower-case) so the two compare equal.</para>
/// <para><b>Address-space-rebuild interaction (accepted trade-off).</b> These three fields participate in
/// <see cref="EquipmentNode"/>'s record value-equality, which <c>AddressSpacePlan.Compute</c> uses to
/// build its changed-equipment set. So editing a <c>Device</c>'s <c>DeviceConfig</c> host/port, or
/// rebinding an equipment's <c>DriverInstanceId</c> / <c>DeviceId</c>, now yields an
/// <see cref="EquipmentNode"/> delta that triggers a full structural address-space rebuild on the next
/// deploy (a momentary subscription teardown for that equipment). This is a deliberate, accepted
/// decision: it fires only on rare operator-initiated config edits at deploy time (routine redeploys of
/// unchanged config are unaffected — the delta is empty), it is recoverable, and it is directionally
/// correct for the multi-device FixedTree re-partition (a later task). <c>AddressSpacePlan</c> is left
/// unchanged.</para></summary>
public sealed record EquipmentNode(
    string EquipmentId,
    string DisplayName,
    string UnsLineId,
    string? DriverInstanceId = null,
    string? DeviceId = null,
    string? DeviceHost = null);
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);

/// <summary>
///     One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
///     is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable
///     <see cref="TagId"/> (diff identity), the parent <see cref="EquipmentId"/> folder (already
///     materialised by <c>AddressSpaceApplier.MaterialiseHierarchy</c>) the variable hangs under, the
///     optional <see cref="FolderPath"/> sub-folder, the leaf <see cref="Name"/> display, the OPC UA
///     <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
///     <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
///     is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
///     (e.g. a Modbus register) is not unique across identical machines. <see cref="Writable"/>
///     mirrors the authored <c>Tag.AccessLevel == ReadWrite</c> so the materialised node is created
///     <c>CurrentReadWrite</c> (the prerequisite for the inbound-write pipeline); a <c>Read</c> tag
///     stays read-only. This flag is derived identically on the artifact-decode side
///     (<c>DeploymentArtifact.BuildEquipmentTagPlans</c>) for byte-parity. <see cref="Alarm"/> carries
///     the optional native-alarm intent parsed from <c>Tag.TagConfig</c>'s <c>alarm</c> object (null ⇒
///     a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity.
///     <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> carry the optional server-side
///     HistoryRead intent parsed from <c>Tag.TagConfig</c>'s <c>isHistorized</c> bool +
///     <c>historianTagname</c> string (Phase C); a null <see cref="HistorianTagname"/> means the historian
///     tagname defaults to <see cref="FullName"/> (resolved later, not here). Both are parsed identically
///     on the artifact-decode side for byte-parity.
///     <see cref="IsArray"/> / <see cref="ArrayLength"/> carry the optional array intent parsed from
///     <c>Tag.TagConfig</c>'s <c>isArray</c> bool + <c>arrayLength</c> uint: when <see cref="IsArray"/>
///     the variable materialises as a 1-D array (<c>ValueRank=OneDimension</c>,
///     <c>ArrayDimensions=[ArrayLength]</c>) rather than a scalar. A null <see cref="ArrayLength"/> means
///     length 0 (unbounded). Both are parsed identically on the artifact-decode side for byte-parity.
/// </summary>
public sealed record EquipmentTagPlan(
    string TagId,
    string EquipmentId,
    string DriverInstanceId,
    string FolderPath,
    string Name,
    string DataType,
    string FullName,
    bool Writable,
    EquipmentTagAlarmInfo? Alarm,
    bool IsHistorized = false,
    string? HistorianTagname = null,
    bool IsArray = false,
    uint? ArrayLength = null);

/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.
/// <see cref="HistorizeToAveva"/> is the per-condition opt-out of the durable AVEVA historian write
/// (<c>bool?</c>; absent ⇒ <c>null</c> ⇒ historize). It is threaded onto each native
/// <c>AlarmTransitionEvent</c> so the runtime's <c>HistorianAdapterActor</c> gate
/// (<c>historizeToAveva is not false</c>) suppresses the durable row only on an explicit <c>false</c> —
/// the same posture as the scripted-alarm opt-out; the live <c>/alerts</c> fan-out is unaffected.</summary>
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool? HistorizeToAveva = null);

/// <summary>
///     One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
///     <see cref="Script"/> for the expression). The VirtualTag value analogue of
///     <see cref="EquipmentTagPlan"/>: <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c>
///     materialises each as a Variable under its equipment folder with a folder-scoped NodeId
///     (<c>EquipmentId/Name</c>, or <c>EquipmentId/FolderPath/Name</c> when a sub-folder is set),
///     and <c>VirtualTagHostActor</c> spawns a <c>VirtualTagActor</c> per plan that evaluates
///     <see cref="Expression"/> over <see cref="DependencyRefs"/> and publishes the value back to
///     that NodeId. <see cref="DependencyRefs"/> = the distinct <c>ctx.GetTag("…")</c> literals in
///     the script source.
/// </summary>
/// <param name="Historize">When true, this VirtualTag's values are historized (carried from the
/// <c>VirtualTag.Historize</c> entity column). Threaded through the deploy-diff equality below so a
/// Historize-only toggle is detected as a change. Defaults to <c>false</c> — matching both the CLR
/// default of the <c>bool VirtualTag.Historize</c> column and the artifact-decode default when the flag is absent/non-bool —
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.</param>
public sealed record EquipmentVirtualTagPlan(
    string VirtualTagId,
    string EquipmentId,
    string FolderPath,
    string Name,
    string DataType,
    string Expression,
    IReadOnlyList<string> DependencyRefs,
    bool Historize = false)
{
    /// <inheritdoc />
    // Structural equality: the auto-generated record equality would compare DependencyRefs (an
    // interface-typed list) BY REFERENCE, flagging every VirtualTag as "changed" on every parse
    // (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty. Historize
    // is included so a Historize-only toggle is detected as a change.
    public bool Equals(EquipmentVirtualTagPlan? other) =>
        other is not null &&
        VirtualTagId == other.VirtualTagId &&
        EquipmentId == other.EquipmentId &&
        FolderPath == other.FolderPath &&
        Name == other.Name &&
        DataType == other.DataType &&
        Expression == other.Expression &&
        Historize == other.Historize &&
        DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);

    /// <inheritdoc />
    public override int GetHashCode()
    {
        var hash = new HashCode();
        hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
        hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
        hash.Add(Historize);
        foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
        return hash.ToHashCode();
    }
}

/// <summary>
///     One Equipment-owned scripted alarm from a <see cref="ScriptedAlarm"/> row, joined to its
///     predicate <see cref="Script"/> (by <see cref="PredicateScriptId"/>) for the source. The
///     richer host analogue of the thin <see cref="ScriptedAlarmPlan"/>: the runtime alarm host
///     spawns one Part 9 condition per plan, evaluates <see cref="PredicateSource"/> over
///     <see cref="DependencyRefs"/>, and resolves the <see cref="MessageTemplate"/>'s
///     <c>{TagPath}</c> tokens at emission time. <see cref="DependencyRefs"/> = the distinct
///     <c>ctx.GetTag("…")</c> read literals in the predicate source UNION the distinct
///     <c>{TagPath}</c> token paths referenced in the message template (the reserved
///     <c>{{equip}}</c> double-brace form is excluded). <see cref="Enabled"/> is carried (never
///     dropped) so the host — not the composer — decides whether to host a disabled alarm. Designed
///     to be cleanly serializable so the artifact-decode seam (sibling task) can mirror it for
///     byte-parity, exactly like <see cref="EquipmentVirtualTagPlan"/>.
/// </summary>
/// <param name="ScriptedAlarmId">Stable logical id — drives the condition name + diff identity.</param>
/// <param name="EquipmentId">Owning equipment folder the alarm hangs under.</param>
/// <param name="Name">Operator-facing alarm name.</param>
/// <param name="AlarmType">Concrete Part 9 type ("AlarmCondition"/"LimitAlarm"/"OffNormalAlarm"/"DiscreteAlarm").</param>
/// <param name="Severity">Numeric severity 1..1000 per OPC UA Part 9.</param>
/// <param name="MessageTemplate">Template with <c>{TagPath}</c> tokens resolved at emission time.</param>
/// <param name="PredicateScriptId">Logical FK to the predicate script.</param>
/// <param name="PredicateSource">The resolved predicate script source (joined by <paramref name="PredicateScriptId"/>).</param>
/// <param name="DependencyRefs">Distinct predicate read refs UNION message-template token paths.</param>
/// <param name="HistorizeToAveva">When true, transitions route to the Aveva Historian sink.</param>
/// <param name="Retain">OPC UA Part 9 <c>Retain</c> flag.</param>
/// <param name="Enabled">Whether the alarm is enabled — carried for the host to decide on.</param>
public sealed record EquipmentScriptedAlarmPlan(
    string ScriptedAlarmId,
    string EquipmentId,
    string Name,
    string AlarmType,
    int Severity,
    string MessageTemplate,
    string PredicateScriptId,
    string PredicateSource,
    IReadOnlyList<string> DependencyRefs,
    bool HistorizeToAveva,
    bool Retain,
    bool Enabled)
{
    /// <inheritdoc />
    // Structural equality: the auto-generated record equality would compare DependencyRefs (an
    // interface-typed list) BY REFERENCE, flagging every alarm as "changed" on every parse (fresh
    // list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
    // EquipmentVirtualTagPlan). DependencyRefs equality is order-sensitive (SequenceEqual).
    // EquipmentScriptPaths.ExtractAlarmDependencyRefs is the canonical, deterministic producer of
    // that order (predicate ctx.GetTag reads first, then first-seen message template tokens).
    // Downstream byte-parity between the live composer and the artifact-decode mirror depends on
    // both sides calling ExtractAlarmDependencyRefs with identical inputs.
    public bool Equals(EquipmentScriptedAlarmPlan? other) =>
        other is not null &&
        ScriptedAlarmId == other.ScriptedAlarmId &&
        EquipmentId == other.EquipmentId &&
        Name == other.Name &&
        AlarmType == other.AlarmType &&
        Severity == other.Severity &&
        MessageTemplate == other.MessageTemplate &&
        PredicateScriptId == other.PredicateScriptId &&
        PredicateSource == other.PredicateSource &&
        HistorizeToAveva == other.HistorizeToAveva &&
        Retain == other.Retain &&
        Enabled == other.Enabled &&
        DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);

    /// <inheritdoc />
    public override int GetHashCode()
    {
        var hash = new HashCode();
        hash.Add(ScriptedAlarmId); hash.Add(EquipmentId); hash.Add(Name);
        hash.Add(AlarmType); hash.Add(Severity); hash.Add(MessageTemplate);
        hash.Add(PredicateScriptId); hash.Add(PredicateSource);
        hash.Add(HistorizeToAveva); hash.Add(Retain); hash.Add(Enabled);
        foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
        return hash.ToHashCode();
    }
}

/// <summary>
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
/// startup consumes the result and hands it to the node-manager factory.
///
/// The composer also carries UNS topology (<see cref="UnsAreaProjection"/> +
/// <see cref="UnsLineProjection"/>) so <c>AddressSpaceApplier</c> can build the
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
/// (composer → applier → sink → node manager) chain.
/// </summary>
public static class AddressSpaceComposer
{
    /// <summary>Convenience overload for legacy callers + tests that don't supply UNS topology or tags.</summary>
    /// <param name="equipment">The equipment.</param>
    /// <param name="driverInstances">The driver instances.</param>
    /// <param name="scriptedAlarms">The scripted alarms.</param>
    /// <returns>The composition result.</returns>
    public static AddressSpaceComposition Compose(
        IReadOnlyList<Equipment> equipment,
        IReadOnlyList<DriverInstance> driverInstances,
        IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
        Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);

    /// <summary>
    ///     Composes the address space build plan from the v3 configuration entities.
    ///     <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
    ///     <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
    ///     dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
    ///     per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
    ///     <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
    ///     mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
    /// </summary>
    /// <param name="unsAreas">The UNS areas.</param>
    /// <param name="unsLines">The UNS lines.</param>
    /// <param name="equipment">The equipment.</param>
    /// <param name="driverInstances">The driver instances.</param>
    /// <param name="scriptedAlarms">The scripted alarms.</param>
    /// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
    /// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
    /// <param name="unsTagReferences">The UNS tag references (equipment → raw tag). Feed the <c>{{equip}}/&lt;RefName&gt;</c> reference map. <c>null</c> = none.</param>
    /// <param name="tags">The raw tags backing <paramref name="unsTagReferences"/> (RawPath computed via the shared resolver). <c>null</c> = none.</param>
    /// <param name="rawFolders">The raw-tree folders (RawPath ancestry). <c>null</c> = none.</param>
    /// <param name="devices">The raw-tree devices (RawPath ancestry). <c>null</c> = none.</param>
    /// <param name="tagGroups">The raw-tree tag-groups (RawPath ancestry). <c>null</c> = none.</param>
    /// <returns>The composition result.</returns>
    public static AddressSpaceComposition Compose(
        IReadOnlyList<UnsArea> unsAreas,
        IReadOnlyList<UnsLine> unsLines,
        IReadOnlyList<Equipment> equipment,
        IReadOnlyList<DriverInstance> driverInstances,
        IReadOnlyList<ScriptedAlarm> scriptedAlarms,
        IReadOnlyList<VirtualTag>? virtualTags = null,
        IReadOnlyList<Script>? scripts = null,
        IReadOnlyList<UnsTagReference>? unsTagReferences = null,
        IReadOnlyList<Tag>? tags = null,
        IReadOnlyList<RawFolder>? rawFolders = null,
        IReadOnlyList<Device>? devices = null,
        IReadOnlyList<TagGroup>? tagGroups = null)
    {
        var vtags = virtualTags ?? Array.Empty<VirtualTag>();
        var resolvedScripts = scripts ?? Array.Empty<Script>();

        var areas = unsAreas
            .OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
            .Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
            .ToList();

        var lines = unsLines
            .OrderBy(l => l.UnsLineId, StringComparer.Ordinal)
            .Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name))
            .ToList();

        var nodes = equipment
            .OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
            // DisplayName = the UNS level-5 Name segment (friendly browse name). NodeId stays the logical
            // EquipmentId. v3: equipment no longer binds a driver/device (it references raw tags via
            // UnsTagReference), so DriverInstanceId/DeviceId/DeviceHost are always null.
            .Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
            .ToList();

        var plans = driverInstances
            .OrderBy(d => d.DriverInstanceId, StringComparer.Ordinal)
            .Select(d => new DriverInstancePlan(d.DriverInstanceId, d.DriverType, d.DriverConfig))
            .ToList();

        var alarms = scriptedAlarms
            .OrderBy(a => a.ScriptedAlarmId, StringComparer.Ordinal)
            .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
            .ToList();

        // v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
        var equipmentTags = Array.Empty<EquipmentTagPlan>();

        // Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
        // from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
        // RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
        // use, so the three agree byte-for-byte on the resolved RawPath.
        var referenceMapByEquip = BuildEquipmentReferenceMap(
            unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
        var emptyRefMap = (IReadOnlyDictionary<string, string>)
            new Dictionary<string, string>(StringComparer.Ordinal);

        // Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
        // expression source. Each {{equip}}/<RefName> is substituted with the owning equipment's backing
        // RawPath BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
        // DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
        // VirtualTag has no FolderPath today → "".
        var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
        var equipmentVirtualTags = vtags
            .OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
            .ThenBy(v => v.Name, StringComparer.Ordinal)
            .Select(v =>
            {
                var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
                var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
                    src, referenceMapByEquip.GetValueOrDefault(v.EquipmentId, emptyRefMap));
                return new EquipmentVirtualTagPlan(
                    VirtualTagId: v.VirtualTagId,
                    EquipmentId: v.EquipmentId,
                    FolderPath: string.Empty,
                    Name: v.Name,
                    DataType: v.DataType,
                    Expression: expanded,
                    DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
                    Historize: v.Historize);
            })
            .ToList();

        // Equipment scripted alarms = each ScriptedAlarm joined to its predicate Script (by
        // PredicateScriptId) for the source. An alarm whose PredicateScriptId has no matching Script
        // row is SKIPPED (not emitted) with a structured warning rather than failing the whole
        // compose — the upstream draft validator is the authority that should prevent the dangling
        // reference. DependencyRefs = the predicate's distinct ctx.GetTag("…") reads UNION the
        // distinct {TagPath} tokens in the MessageTemplate (predicate reads first, then first-seen
        // template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried
        // (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by
        // EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable.
        //
        // Eager foreach (not lazy LINQ Select) so the Trace.TraceWarning fires exactly once per
        // compose call; a lazy select would re-fire on every re-enumeration of the LINQ chain.
        var equipmentScriptedAlarms = new List<EquipmentScriptedAlarmPlan>();
        foreach (var a in scriptedAlarms
            .OrderBy(a => a.EquipmentId, StringComparer.Ordinal)
            .ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal))
        {
            if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s))
            {
                Trace.TraceWarning(
                    "AddressSpaceComposer: scripted alarm '{0}' (equipment '{1}') references predicate " +
                    "script '{2}' which is not in the supplied scripts — skipping.",
                    a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
                continue;
            }
            // v3: scripted-alarm predicates ALSO resolve {{equip}}/<RefName> (equipment-scoped scripts),
            // substituted with the owning equipment's reference map BEFORE the dependency merge — so the
            // stored PredicateSource + DependencyRefs are resolved RawPaths, byte-parity with the
            // artifact-decode mirror. The merge (predicate reads first, then template tokens) lives in the
            // shared EquipmentScriptPaths helper.
            var source = EquipmentScriptPaths.SubstituteEquipmentToken(
                s.SourceCode, referenceMapByEquip.GetValueOrDefault(a.EquipmentId, emptyRefMap));
            equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
                ScriptedAlarmId: a.ScriptedAlarmId,
                EquipmentId: a.EquipmentId,
                Name: a.Name,
                AlarmType: a.AlarmType,
                Severity: a.Severity,
                MessageTemplate: a.MessageTemplate,
                PredicateScriptId: a.PredicateScriptId,
                PredicateSource: source,
                DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
                HistorizeToAveva: a.HistorizeToAveva,
                Retain: a.Retain,
                Enabled: a.Enabled));
        }

        return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
        {
            EquipmentTags = equipmentTags,
            EquipmentVirtualTags = equipmentVirtualTags,
            EquipmentScriptedAlarms = equipmentScriptedAlarms,
        };
    }

    /// <summary>Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map from the v3 entities
    /// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
    /// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
    /// are supplied (every test / earlier caller that omits the reference data).</summary>
    private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
        IReadOnlyList<UnsTagReference>? unsTagReferences,
        IReadOnlyList<Tag>? tags,
        IReadOnlyList<RawFolder>? rawFolders,
        IReadOnlyList<DriverInstance> driverInstances,
        IReadOnlyList<Device>? devices,
        IReadOnlyList<TagGroup>? tagGroups)
    {
        var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
        var tagRows = tags ?? Array.Empty<Tag>();
        if (refs.Count == 0 || tagRows.Count == 0)
            return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);

        var folders = (rawFolders ?? Array.Empty<RawFolder>())
            .GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
        var drivers = driverInstances
            .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
        var deviceMap = (devices ?? Array.Empty<Device>())
            .GroupBy(d => d.DeviceId, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
        var groups = (tagGroups ?? Array.Empty<TagGroup>())
            .GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
        var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);

        var tagsById = tagRows
            .GroupBy(t => t.TagId, StringComparer.Ordinal)
            .ToDictionary(
                g => g.Key,
                g => new EquipmentReferenceMap.TagRow(g.First().Name, g.First().DeviceId, g.First().TagGroupId),
                StringComparer.Ordinal);

        return EquipmentReferenceMap.Build(
            refs.Select(r => new EquipmentReferenceMap.ReferenceRow(
                r.UnsTagReferenceId, r.EquipmentId, r.TagId, r.DisplayNameOverride)),
            tagsById,
            resolver);
    }
}
