using System.Diagnostics;
using System.Text.Json;
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="Phase7Composer.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.
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
public sealed record Phase7CompositionResult(
    IReadOnlyList<UnsAreaProjection> UnsAreas,
    IReadOnlyList<UnsLineProjection> UnsLines,
    IReadOnlyList<EquipmentNode> EquipmentNodes,
    IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
    IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans,
    IReadOnlyList<GalaxyTagPlan> GalaxyTags)
{
    /// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy 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 Phase7CompositionResult(
        IReadOnlyList<EquipmentNode> equipmentNodes,
        IReadOnlyList<DriverInstancePlan> driverInstancePlans,
        IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
        : this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
               equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
    {
    }

    /// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
    /// <param name="unsAreas">The UNS areas.</param>
    /// <param name="unsLines">The UNS lines.</param>
    /// <param name="equipmentNodes">The equipment nodes.</param>
    /// <param name="driverInstancePlans">The driver instance plans.</param>
    /// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
    public Phase7CompositionResult(
        IReadOnlyList<UnsAreaProjection> unsAreas,
        IReadOnlyList<UnsLineProjection> unsLines,
        IReadOnlyList<EquipmentNode> equipmentNodes,
        IReadOnlyList<DriverInstancePlan> driverInstancePlans,
        IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
        : this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
    {
    }

    /// <summary>
    ///     Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
    ///     in an <c>Equipment</c>-kind namespace. Mirror of <see cref="GalaxyTags"/> for the UNS
    ///     equipment-signal path: <c>Phase7Applier.MaterialiseEquipmentTags</c> materialises each as
    ///     a Variable under its existing equipment folder. Declared as an init-only member defaulting
    ///     to empty (rather than a 7th 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);
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);

/// <summary>
///     One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
///     <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
///     turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
///     <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
///     that the Galaxy driver consumes when subscribing.
/// </summary>
public sealed record GalaxyTagPlan(
    string TagId,
    string DriverInstanceId,
    string FolderPath,
    string DisplayName,
    string DataType,
    string MxAccessRef);

/// <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>Phase7Applier.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. The equipment-signal
///     analogue of <see cref="GalaxyTagPlan"/>.
/// </summary>
public sealed record EquipmentTagPlan(
    string TagId,
    string EquipmentId,
    string DriverInstanceId,
    string FolderPath,
    string Name,
    string DataType,
    string FullName);

/// <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>Phase7Applier.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>
public sealed record EquipmentVirtualTagPlan(
    string VirtualTagId,
    string EquipmentId,
    string FolderPath,
    string Name,
    string DataType,
    string Expression,
    IReadOnlyList<string> DependencyRefs)
{
    /// <summary>Structural equality: the auto-generated record equality would compare
    /// <see cref="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.</summary>
    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 &&
        DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);

    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);
        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)
{
    /// <summary>Structural equality: the auto-generated record equality would compare
    /// <see cref="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 <see cref="EquipmentVirtualTagPlan"/>).</summary>
    /// <remarks>
    ///     <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
    ///     <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
    ///     producer of that order (predicate <c>ctx.GetTag</c> 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 <c>ExtractAlarmDependencyRefs</c> with identical inputs.
    /// </remarks>
    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 (Task 53) consumes the result and hands it to the node-manager factory.
///
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
/// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</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 Phase7Composer
{
    /// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</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 Phase7CompositionResult Compose(
        IReadOnlyList<Equipment> equipment,
        IReadOnlyList<DriverInstance> driverInstances,
        IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
        Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
                Array.Empty<Tag>(), Array.Empty<Namespace>());

    /// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</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>
    /// <returns>The composition result.</returns>
    public static Phase7CompositionResult Compose(
        IReadOnlyList<UnsArea> unsAreas,
        IReadOnlyList<UnsLine> unsLines,
        IReadOnlyList<Equipment> equipment,
        IReadOnlyList<DriverInstance> driverInstances,
        IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
        Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
                Array.Empty<Tag>(), Array.Empty<Namespace>());

    /// <summary>
    ///     Composes the address space build plan from the configuration entities.
    /// </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="tags">The tags.</param>
    /// <param name="namespaces">The namespaces.</param>
    /// <param name="virtualTags">The Equipment-namespace 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>
    /// <returns>The composition result.</returns>
    public static Phase7CompositionResult Compose(
        IReadOnlyList<UnsArea> unsAreas,
        IReadOnlyList<UnsLine> unsLines,
        IReadOnlyList<Equipment> equipment,
        IReadOnlyList<DriverInstance> driverInstances,
        IReadOnlyList<ScriptedAlarm> scriptedAlarms,
        IReadOnlyList<Tag> tags,
        IReadOnlyList<Namespace> namespaces,
        IReadOnlyList<VirtualTag>? virtualTags = null,
        IReadOnlyList<Script>? scripts = 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, matching the Area
            // and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
            // stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
            .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();

        // SystemPlatform tags = Galaxy tags. Match each tag to its DriverInstance and that
        // driver's Namespace; emit only when the namespace kind is SystemPlatform AND the tag
        // has no EquipmentId (per the entity invariant for SystemPlatform).
        var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
        var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);

        var galaxyTags = tags
            .Where(t => t.EquipmentId is null)
            .Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
                        && namespacesById.TryGetValue(di.NamespaceId, out var ns)
                        && ns.Kind == NamespaceKind.SystemPlatform)
            .OrderBy(t => t.DriverInstanceId, StringComparer.Ordinal)
            .ThenBy(t => t.FolderPath, StringComparer.Ordinal)
            .ThenBy(t => t.Name, StringComparer.Ordinal)
            .Select(t => new GalaxyTagPlan(
                TagId: t.TagId,
                DriverInstanceId: t.DriverInstanceId,
                FolderPath: t.FolderPath ?? string.Empty,
                DisplayName: t.Name,
                DataType: t.DataType,
                // MXAccess reference: "FolderPath.Name" when FolderPath is set, else just "Name".
                MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}"))
            .ToList();

        // Equipment tags = the inverse filter: a Tag bound to an Equipment (non-null EquipmentId)
        // whose driver's namespace is Equipment-kind. FullName is the driver-side wire reference
        // pulled from TagConfig — it becomes the variable's NodeId + read/write routing key.
        var equipmentTags = tags
            .Where(t => t.EquipmentId is not null)
            .Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
                        && namespacesById.TryGetValue(di.NamespaceId, out var ns)
                        && ns.Kind == NamespaceKind.Equipment)
            .OrderBy(t => t.EquipmentId, StringComparer.Ordinal)
            .ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly
            .ThenBy(t => t.Name, StringComparer.Ordinal)
            .Select(t => new EquipmentTagPlan(
                TagId: t.TagId,
                EquipmentId: t.EquipmentId!,
                DriverInstanceId: t.DriverInstanceId,
                FolderPath: t.FolderPath ?? string.Empty,
                Name: t.Name,
                DataType: t.DataType,
                FullName: ExtractTagFullName(t.TagConfig)))
            .ToList();

        // Per-equipment tag base = the shared substring-before-first-dot across each equipment's
        // child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
        // scripts (equipment-relative tag paths). Derived from equipmentTags so one script reused
        // across N identical machines resolves to N machine-specific dependency graphs.
        var baseByEquip = equipmentTags
            .GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
            .ToDictionary(
                g => g.Key,
                g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
                StringComparer.Ordinal);

        // Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
        // expression source. The {{equip}} token is substituted with the owning equipment's tag
        // base 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, baseByEquip.GetValueOrDefault(v.EquipmentId));
                return new EquipmentVirtualTagPlan(
                    VirtualTagId: v.VirtualTagId,
                    EquipmentId: v.EquipmentId,
                    FolderPath: string.Empty,
                    Name: v.Name,
                    DataType: v.DataType,
                    Expression: expanded,
                    DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded));
            })
            .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(
                    "Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " +
                    "script '{2}' which is not in the supplied scripts — skipping.",
                    a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
                continue;
            }
            var source = s.SourceCode;
            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,
                // Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
                // predicate source as-is. The merge (predicate reads first, then template tokens) lives
                // in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
                DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
                HistorizeToAveva: a.HistorizeToAveva,
                Retain: a.Retain,
                Enabled: a.Enabled));
        }

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

    /// <summary>
    ///     Extract the driver-side full reference from a <see cref="Tag.TagConfig"/> JSON blob: the
    ///     <c>CK_Tag_TagConfig_IsJson</c> constraint guarantees a JSON object, and every shipped
    ///     driver stores the wire-level address in a top-level <c>FullName</c> field. Replicated from
    ///     <c>EquipmentNodeWalker.ExtractFullName</c> because OpcUaServer does not reference the Core
    ///     driver assembly (kept in sync with the artifact-decode copy in <c>DeploymentArtifact</c>).
    ///     Falls back to the raw blob when it is not a JSON object with a string <c>FullName</c>.
    /// </summary>
    /// <param name="tagConfig">The tag's wire-level address JSON.</param>
    /// <returns>The extracted full reference, or the raw blob when no <c>FullName</c> is present.</returns>
    private static string ExtractTagFullName(string tagConfig)
    {
        if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
        try
        {
            using var doc = JsonDocument.Parse(tagConfig);
            if (doc.RootElement.ValueKind == JsonValueKind.Object
                && doc.RootElement.TryGetProperty("FullName", out var fullName)
                && fullName.ValueKind == JsonValueKind.String)
            {
                return fullName.GetString() ?? tagConfig;
            }
        }
        catch (JsonException) { /* fall through to raw blob */ }
        return tagConfig;
    }
}
