using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     Side-effecting orchestrator over <see cref="AddressSpacePlan"/>. Drives an
///     <see cref="IOpcUaAddressSpaceSink"/> to materialise the diff between two
///     <see cref="AddressSpaceComposition"/> snapshots, routing each delta class to its MINIMAL mutation
///     (R2-07 03/P1) via <see cref="AddressSpaceChangeClassifier"/> instead of rebuilding the whole
///     address space for any topology change (which would kill every client's server-wide subscriptions):
///
///     <list type="bullet">
///         <item><b>PureAdd</b> — no rebuild. <c>OpcUaPublishActor</c>'s idempotent Materialise passes
///             create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
///             <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
///             model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
///         <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed tags + UNS folder renames
///             are applied IN PLACE via <see cref="ISurgicalAddressSpaceSink"/> (F10b). Any <c>false</c>
///             or throw from a surgical call falls back to a full rebuild (safe default).</item>
///         <item><b>PureRemove</b> — scoped in-place teardown of only the removed subtree (writes a
///             terminal Bad / <c>RemovedConditionState</c> first so in-flight MonitoredItems observe the
///             removal). Maps to a full rebuild until R2-07 Phase 2 ships.</item>
///         <item><b>AddRemoveMix</b> — removes-then-adds within one apply. Maps to a full rebuild until
///             R2-07 Phase 3 ships.</item>
///         <item><b>Rebuild</b> — the default-closed safety valve for any node-affecting change
///             (ChangedEquipment / ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant
///             ChangedEquipmentVirtualTags), so the worst outcome of any misclassification is today's
///             full-rebuild behaviour.</item>
///     </list>
///
///     Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds
///     <see cref="NullOpcUaAddressSpaceSink"/>, and tests can capture every call.
/// </summary>
public sealed class AddressSpaceApplier
{
    private readonly IOpcUaAddressSpaceSink _sink;
    private readonly ILogger<AddressSpaceApplier> _logger;
    private readonly IHistorianProvisioning _provisioning;
    private readonly IHistorizedTagSubscriptionSink _historizedSubscriptions;

    /// <summary>Initializes a new instance of the AddressSpaceApplier class.</summary>
    /// <param name="sink">The OPC UA address space sink to apply changes to.</param>
    /// <param name="logger">The logger instance.</param>
    /// <param name="provisioning">
    ///     Optional historian tag provisioner — when an address space is (re)built, historized added
    ///     tags are auto-ensured in the historian via <see cref="IHistorianProvisioning.EnsureTagsAsync"/>.
    ///     Defaults (a <c>null</c> argument) to the no-op <see cref="NullHistorianProvisioning"/>, so every
    ///     existing two-argument call site compiles and behaves unchanged. The provisioning round-trip is
    ///     dispatched fire-and-forget off <see cref="Apply"/> (which runs on the OPC UA publish actor's
    ///     pinned thread), so it can never block or break a deploy.
    /// </param>
    /// <param name="historizedSubscriptions">
    ///     Optional continuous-historization feed — when an address space is (re)applied, the add/remove
    ///     delta of historized tag refs (resolved EXACTLY as the provisioning hook above) is pushed to the
    ///     recorder so its dependency-mux interest converges to the currently-historized set. Defaults
    ///     (a <c>null</c> argument) to the no-op <see cref="NullHistorizedTagSubscriptionSink"/>, so every
    ///     existing call site compiles and behaves unchanged. The feed is a single non-blocking post off
    ///     <see cref="Apply"/> and is wrapped so it can never block or break a deploy.
    /// </param>
    public AddressSpaceApplier(
        IOpcUaAddressSpaceSink sink,
        ILogger<AddressSpaceApplier> logger,
        IHistorianProvisioning? provisioning = null,
        IHistorizedTagSubscriptionSink? historizedSubscriptions = null)
    {
        ArgumentNullException.ThrowIfNull(sink);
        ArgumentNullException.ThrowIfNull(logger);
        _sink = sink;
        _logger = logger;
        _provisioning = provisioning ?? NullHistorianProvisioning.Instance;
        _historizedSubscriptions = historizedSubscriptions ?? NullHistorizedTagSubscriptionSink.Instance;
    }

    /// <summary>
    ///     Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
    ///     callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
    /// </summary>
    /// <param name="plan">The plan to apply.</param>
    /// <returns>A AddressSpaceApplyOutcome summarizing the applied changes.</returns>
    public AddressSpaceApplyOutcome Apply(AddressSpacePlan plan)
    {
        ArgumentNullException.ThrowIfNull(plan);

        if (plan.IsEmpty)
        {
            _logger.LogDebug("AddressSpaceApplier: plan is empty; skipping sink writes");
            return new AddressSpaceApplyOutcome(RemovedNodes: 0, AddedNodes: 0, ChangedNodes: 0, RebuildCalled: false);
        }

        var ts = DateTime.UtcNow;
        var removedCount = 0;
        // Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning)
        // so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1).
        var failedNodes = 0;
        foreach (var eq in plan.RemovedEquipment)
        {
            if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++;
            removedCount++;
        }
        foreach (var alarm in plan.RemovedAlarms)
        {
            if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++;
            removedCount++;
        }
        // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
        // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
        // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
        removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;

        var changedCount =
            plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
            plan.ChangedEquipmentTags.Count +
            plan.ChangedEquipmentVirtualTags.Count +
            // A UNS Area/Line rename is an in-place change to an existing folder node.
            plan.RenamedFolders.Count;
        var addedCount =
            plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
            plan.AddedEquipmentTags.Count +
            plan.AddedEquipmentVirtualTags.Count;

        // R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
        // rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
        // the planner's diff (the planner stays a pure diff). The routing here:
        //   • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in
        //     OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so
        //     every client subscription server-wide survives; coincident surgical tag deltas + folder
        //     renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
        //     back to a full rebuild — the F10b contract).
        //   • PureRemove ⇒ NO full rebuild. The removed nodes are torn down IN PLACE, scoped to the affected
        //     subtree, via the ISurgicalAddressSpaceSink remove members below (each preceded by a terminal
        //     Bad / RemovedConditionState write so in-flight MonitoredItems observe the removal); subscribers
        //     of OTHER nodes are untouched. Any false/throw from a remove ⇒ full-rebuild fallback (ratchet).
        //   • AddRemoveMix ⇒ NO full rebuild. Removes-then-adds within one apply: the removed nodes are torn
        //     down IN PLACE here (the PureRemove pass), then OpcUaPublishActor's idempotent Materialise passes
        //     create the added nodes and AnnounceAddedNodes announces them — natural remove-then-recreate
        //     order even when an id is reused across the remove + add sets (the recreated node is a fresh
        //     NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
        //   • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
        //     ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
        //     — the classifier's rule 2, which also catches any future plan field that makes a changed
        //     record unequal, so the worst outcome of a misclassification is today's full rebuild.
        // ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
        // a driver-only plan AttributeOnly, so it never rebuilds here.
        var kind = AddressSpaceChangeClassifier.Classify(plan);
        var mustRebuild = kind is AddressSpaceChangeKind.Rebuild;
        // PureRemove + AddRemoveMix both run the in-place remove pass below (AddRemoveMix's adds are handled
        // afterward by the publish actor's Materialise passes + announce).
        var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;

        var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
        // UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
        // when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
        // MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
        // covered for free and need no separate surgical pass.
        var renamedFolders = plan.RenamedFolders;
        var rebuilt = false;
        var rebuildFailed = false;

        if (mustRebuild)
        {
            rebuildFailed = !SafeRebuild();
            rebuilt = true;
        }
        else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass)
        {
            if (_sink is ISurgicalAddressSpaceSink surgical)
            {
                var allApplied = true;
                // Folder renames first — an in-place DisplayName swap on the existing Area/Line folder.
                foreach (var rename in renamedFolders)
                {
                    bool ok;
                    try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
                        ok = false;
                    }
                    if (!ok) { allApplied = false; break; }
                }
                foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
                {
                    // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
                    // would so the in-place update matches what a rebuild would have produced. Array tags are
                    // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
                    var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
                    var writable = d.Current.Writable && !d.Current.IsArray;
                    var historian = d.Current.IsHistorized
                        ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
                        : null;
                    bool ok;
                    try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
                        ok = false;
                    }
                    if (!ok) { allApplied = false; break; }
                }
                // R2-07 Phase 2/3 — scoped remove teardown for PureRemove AND AddRemoveMix (runs after any
                // coincident surgical attribute updates / renames succeeded). Terminal Bad /
                // RemovedConditionState writes then in-place removes; any false/throw flips allApplied → the
                // rebuild ratchet below. For AddRemoveMix the adds are materialised afterward by the publish
                // actor's passes (remove-then-recreate order).
                if (allApplied && hasRemovePass)
                {
                    allApplied = ApplyPureRemove(surgical, plan, ts, ref failedNodes);
                }
                if (!allApplied) { rebuildFailed = !SafeRebuild(); rebuilt = true; }
            }
            else
            {
                // Sink lacks the surgical capability ⇒ rebuild (safe default).
                rebuildFailed = !SafeRebuild();
                rebuilt = true;
            }
        }

        _logger.LogInformation(
            "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
            kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);

        // After the address-space work has completed, auto-provision the historian for the added
        // historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block
        // or break the deploy — Apply has already produced its outcome and returns it regardless.
        ProvisionHistorizedTags(plan);

        // Alongside provisioning: feed the continuous-historization recorder the add/remove delta of
        // historized tag refs this plan changes, so its dependency-mux interest converges to exactly the
        // currently-historized set. Same non-blocking + throw-safe discipline as the provisioning hook.
        FeedHistorizedRefs(plan);

        return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes);
    }

    /// <summary>
    ///     R2-07 Phase 2 — apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
    ///     rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
    ///     <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
    ///     subtree removal and skipped individually. For surviving-equipment removals: a removed value
    ///     variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
    ///     a removed alarm-bearing tag (the pre-R2-07 today-gap: it got no terminal condition write) and a
    ///     removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
    ///     <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
    ///     <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
    ///     in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
    ///     <c>BadNodeIdUnknown</c>. Returns false on the FIRST remove that reports the node unknown / throws
    ///     (the caller's one-way rebuild ratchet takes over), leaving no further surgical work attempted.
    /// </summary>
    /// <param name="surgical">The surgical sink to route removes through.</param>
    /// <param name="plan">The PureRemove plan.</param>
    /// <param name="ts">The timestamp for the terminal writes.</param>
    /// <param name="failedNodes">Accumulator for swallowed terminal-write failures (archreview 01/S-1).</param>
    /// <returns>True when every removal succeeded; false on the first failure (caller rebuilds).</returns>
    private bool ApplyPureRemove(ISurgicalAddressSpaceSink surgical, AddressSpacePlan plan, DateTime ts, ref int failedNodes)
    {
        var removedEquipmentIds = new HashSet<string>(
            plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
        bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);

        // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
        foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
        {
            var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
            if (tag.Alarm is not null)
            {
                // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
                // closed), then remove the condition in place.
                if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++;
                if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false;
            }
            else
            {
                // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
                SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
                if (!SafeRemoveVariable(surgical, nodeId)) return false;
            }
        }

        // Removed VirtualTags (always plain value variables) for surviving equipment.
        foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
        {
            var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
            SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
            if (!SafeRemoveVariable(surgical, nodeId)) return false;
        }

        // Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
        // written by the top-of-Apply removal block; here we tear the condition node down in place.
        foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
        {
            if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false;
        }

        // Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
        // top-of-Apply block already wrote the equipment id's terminal condition state.
        foreach (var eq in plan.RemovedEquipment)
        {
            if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false;
        }

        return true;
    }

    /// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
    /// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
    private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId)
    {
        try { return surgical.RemoveVariableNode(nodeId); }
        catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
    }

    /// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
    /// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
    private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId)
    {
        try { return surgical.RemoveAlarmConditionNode(nodeId); }
        catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
    }

    /// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
    /// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
    private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId)
    {
        try { return surgical.RemoveEquipmentSubtree(nodeId); }
        catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
    }

    /// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
    /// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
    /// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
    private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
    {
        try { _sink.WriteValue(nodeId, value, quality, ts); return true; }
        catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
    }

    /// <summary>
    ///     Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce
    ///     (Part 3 <c>NodeAdded</c>) after a pure-add apply's Materialise passes have created the new nodes.
    ///     For each added tag/vtag the parent is its materialise folder (the equipment folder, or the
    ///     <c>FolderPath</c> sub-folder — the exact ids the Materialise passes place children at); for each
    ///     added scripted alarm the parent is its equipment folder (where the condition node parents); for
    ///     each added equipment the parent is its UNS line folder when set, else the equipment's own new
    ///     folder id (a valid Part 3 announcement of the folder itself — the root has no announceable id).
    ///     Pure — no sink interaction — so it is unit-testable in isolation and safe to call before the
    ///     nodes exist. Ordinal-sorted so the announce sequence is stable.
    /// </summary>
    /// <param name="plan">The plan whose added nodes' parents to announce.</param>
    /// <returns>The distinct affected parent node ids, ordinal-sorted.</returns>
    public IReadOnlyList<string> ComputeAddAnnouncements(AddressSpacePlan plan)
    {
        ArgumentNullException.ThrowIfNull(plan);

        var ids = new HashSet<string>(StringComparer.Ordinal);
        foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
        foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
        foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
        foreach (var eq in plan.AddedEquipment)
            ids.Add(string.IsNullOrWhiteSpace(eq.UnsLineId) ? eq.EquipmentId : eq.UnsLineId);

        var list = ids.ToList();
        list.Sort(StringComparer.Ordinal);
        return list;
    }

    /// <summary>The materialise-parent node id a tag/vtag's variable (or native-alarm condition) hangs
    /// under: the equipment folder when <paramref name="folderPath"/> is null/empty, else the FolderPath
    /// sub-folder. Mirrors the parent derivation the Materialise passes use.</summary>
    /// <param name="equipmentId">The owning equipment folder id.</param>
    /// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
    /// <returns>The materialise-parent node id.</returns>
    private static string MaterialiseParent(string equipmentId, string? folderPath) =>
        string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);

    /// <summary>
    ///     Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
    ///     <see cref="ComputeAddAnnouncements"/>, so model-aware clients re-browse the parents the new nodes
    ///     were added under. Called by <c>OpcUaPublishActor</c> AFTER the Materialise passes (the nodes exist
    ///     by then) and ONLY on a non-rebuild apply — after a full rebuild the announcement is moot
    ///     (subscriptions are dead anyway). Uses the existing, guard-covered
    ///     <see cref="IOpcUaAddressSpaceSink.RaiseNodesAddedModelChange"/> — no new sink member. Each call is
    ///     Safe-wrapped: a faulting announcement can never break a deploy (the nodes already stand).
    /// </summary>
    /// <param name="plan">The plan whose added nodes to announce.</param>
    public void AnnounceAddedNodes(AddressSpacePlan plan)
    {
        foreach (var id in ComputeAddAnnouncements(plan))
        {
            try { _sink.RaiseNodesAddedModelChange(id); }
            catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
        }
    }

    /// <summary>
    ///     Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
    ///     publish actor's pinned thread, so the synchronous portion is kept to building the request
    ///     list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped
    ///     in try/catch — a synchronously-throwing provisioner (or any request-building fault) is
    ///     swallowed so it cannot break a deploy.
    /// </summary>
    /// <param name="plan">The plan whose added historized tags to ensure in the historian.</param>
    private void ProvisionHistorizedTags(AddressSpacePlan plan)
    {
        try
        {
            List<HistorianTagProvisionRequest>? requests = null;
            foreach (var tag in plan.AddedEquipmentTags)
            {
                // Only historized value variables are provisioned. Native-alarm tags materialise as
                // Part 9 condition nodes (never historized value variables) — the materialiser resolves
                // a historian tagname only for the non-alarm branch, so mirror that and skip them.
                if (!tag.IsHistorized || tag.Alarm is not null) continue;

                // Parse the driver-agnostic data type from the tag's DataType string. An unparseable
                // type is skipped (logged at Debug) rather than faulting the hook.
                if (!Enum.TryParse<DriverDataType>(tag.DataType, ignoreCase: true, out var dataType))
                {
                    _logger.LogDebug(
                        "AddressSpaceApplier: skipping historian provisioning for an added historized tag whose data type '{DataType}' is not a DriverDataType",
                        tag.DataType);
                    continue;
                }

                // Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank
                // override falls back to the driver-side FullName.
                var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname;
                (requests ??= new List<HistorianTagProvisionRequest>()).Add(
                    new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
            }

            if (requests is null) return;

            // Fire-and-forget OFF the apply path. Never await/.Wait()/.Result here — Apply must return
            // its outcome without blocking on the gateway. The continuation observes the task so a
            // faulted provisioning never becomes an unobserved exception, and logs the tally.
            var provisionCount = requests.Count;
            var dispatch = _provisioning.EnsureTagsAsync(requests, CancellationToken.None);
            _ = dispatch.ContinueWith(
                t =>
                {
                    if (!t.IsCompletedSuccessfully)
                    {
                        // Faulted OR canceled — never reach t.Result (which would re-throw and
                        // leave this discarded continuation unobserved).
                        _logger.LogWarning(t.Exception?.GetBaseException(),
                            "AddressSpaceApplier: historian provisioning of {Count} tag(s) did not complete; deploy unaffected", provisionCount);
                        return;
                    }

                    // Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully-
                    // successful provisioning is observable AND a dormant no-op is detectable: the no-op
                    // NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing
                    // dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the
                    // exact invisibility that let the dormant-provisioner bug ship unnoticed.
                    var result = t.Result;
                    _logger.LogInformation(
                        "AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
                        provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed);
                },
                CancellationToken.None,
                TaskContinuationOptions.None,
                TaskScheduler.Default);
        }
        catch (Exception ex)
        {
            // A synchronous fault (e.g. the provisioner throws before returning a task) must not break
            // the deploy. Apply has already produced its outcome.
            _logger.LogWarning(ex, "AddressSpaceApplier: historian provisioning hook faulted synchronously; deploy unaffected");
        }
    }

    /// <summary>
    ///     Feed the continuous-historization recorder the add/remove delta of historized tag refs this
    ///     plan changes, so its dependency-mux interest converges to exactly the currently-historized set
    ///     after every deploy. The plan is a pure DIFF (an incremental/surgical apply carries a delta, not
    ///     the full set), so a delta feed is the only convergent design this hook can produce — the
    ///     recorder keeps the full set and re-registers it. Each ref is resolved EXACTLY as
    ///     <see cref="ProvisionHistorizedTags"/> / <c>MaterialiseEquipmentTags</c> resolve it
    ///     (override-or-FullName), and only non-alarm historized value variables count (native-alarm tags
    ///     materialise as Part 9 condition nodes, never historized value variables). Runs on the OPC UA
    ///     publish actor's pinned thread, so the only work here is building two small ref lists; the
    ///     downstream feed is a single non-blocking post behind the sink. The whole hook is wrapped so a
    ///     faulting feed can never block or break a deploy.
    /// </summary>
    /// <param name="plan">The plan whose historized-ref changes drive the recorder's interest set.</param>
    private void FeedHistorizedRefs(AddressSpacePlan plan)
    {
        try
        {
            List<HistorizedTagRef>? added = null;
            List<HistorizedTagRef>? removed = null;

            // Added historized value variables → new interest.
            foreach (var tag in plan.AddedEquipmentTags)
            {
                if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
            }

            // Removed historized value variables → drop interest.
            foreach (var tag in plan.RemovedEquipmentTags)
            {
                if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
            }

            // Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName
            // change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the
            // mux ref and the historian name) — an unchanged pair is a no-op.
            foreach (var d in plan.ChangedEquipmentTags)
            {
                var prev = HistorizedRef(d.Previous);
                var cur = HistorizedRef(d.Current);
                if (prev == cur) continue;
                if (prev is not null) (removed ??= new List<HistorizedTagRef>()).Add(prev);
                if (cur is not null) (added ??= new List<HistorizedTagRef>()).Add(cur);
            }

            if (added is null && removed is null) return;

            _historizedSubscriptions.UpdateHistorizedRefs(
                added ?? (IReadOnlyList<HistorizedTagRef>)Array.Empty<HistorizedTagRef>(),
                removed ?? (IReadOnlyList<HistorizedTagRef>)Array.Empty<HistorizedTagRef>());
        }
        catch (Exception ex)
        {
            // A synchronous fault in the feed (or in building the ref lists) must not break the deploy.
            // Apply has already produced its outcome.
            _logger.LogWarning(ex, "AddressSpaceApplier: historized-ref subscription feed faulted; deploy unaffected");
        }
    }

    /// <summary>
    ///     Resolve the historized tag ref for <paramref name="tag"/> as a
    ///     <see cref="HistorizedTagRef"/> carrying BOTH identifiers the recorder needs: the
    ///     <c>MuxRef</c> = the driver-side <c>FullName</c> the dependency mux fans values by, and the
    ///     <c>HistorianName</c> = the value the EnsureTags hook / materialiser write under (a non-alarm
    ///     historized value variable's <c>HistorianTagname</c> override, else its <c>FullName</c>). The
    ///     two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
    ///     value variable (not historized, or a native-alarm condition node).
    /// </summary>
    /// <param name="tag">The equipment tag to resolve a historized ref for.</param>
    /// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
    private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) =>
        tag.IsHistorized && tag.Alarm is null
            ? new HistorizedTagRef(
                tag.FullName,
                string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
            : null;

    /// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
    /// Returns <c>true</c> on success, <c>false</c> when the sink threw — the caller threads the
    /// result into <see cref="AddressSpaceApplyOutcome.RebuildFailed"/> so a broken rebuild is
    /// visible instead of reported as optimistic success (archreview 01/S-1).</summary>
    /// <returns><c>true</c> when the rebuild completed; <c>false</c> when the sink threw.</returns>
    private bool SafeRebuild()
    {
        try
        {
            _sink.RebuildAddressSpace();
            return true;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "AddressSpaceApplier: sink.RebuildAddressSpace threw");
            return false;
        }
    }

    /// <summary>
    ///     Build the UNS Area/Line/Equipment folder hierarchy in the address space from a
    ///     composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
    ///     clients browsing the server see proper folder structure instead of flat tag ids.
    ///     Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
    ///     present, so re-applies are cheap.
    /// </summary>
    /// <param name="composition">The composition result containing the hierarchy to materialise.</param>
    /// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
    /// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
    public int MaterialiseHierarchy(AddressSpaceComposition composition)
    {
        ArgumentNullException.ThrowIfNull(composition);

        var failed = 0;
        foreach (var area in composition.UnsAreas)
        {
            if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName)) failed++;
        }
        foreach (var line in composition.UnsLines)
        {
            if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName)) failed++;
        }
        foreach (var equipment in composition.EquipmentNodes)
        {
            // Equipment with no UnsLineId (legacy / dev rows) hang under the root.
            var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
            if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName)) failed++;
        }

        _logger.LogInformation(
            "AddressSpaceApplier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment}, failed={Failed})",
            composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count, failed);
        return failed;
    }

    /// <summary>
    ///     Materialise Equipment-namespace tags from a composition snapshot.
    ///     For each <see cref="EquipmentTagPlan"/>,
    ///     ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
    ///     ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
    ///     start BadWaitingForInitialData; the driver fills live values in a later milestone.
    ///     Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
    ///     sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
    ///     whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
    ///     logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
    ///     variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
    ///     equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
    ///     takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
    /// </summary>
    /// <param name="composition">The composition result containing the equipment tags to materialise.</param>
    /// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
    /// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
    public int MaterialiseEquipmentTags(AddressSpaceComposition composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.EquipmentTags.Count == 0) return 0;

        var failed = 0;
        // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
        // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
        // equipment folder, which MaterialiseHierarchy already created (never re-create
        // the equipment folder here).
        var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
        foreach (var tag in composition.EquipmentTags)
        {
            if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
            var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
            if (!foldersCreated.Add(folderNodeId)) continue;
            if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath)) failed++;
        }

        // Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
        // ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
        // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
        // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
        // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
        // Per-variable idempotency relies on the sink's own EnsureVariable.
        foreach (var tag in composition.EquipmentTags)
        {
            var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
                ? tag.EquipmentId
                : EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
            var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
            if (tag.Alarm is not null)
            {
                // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
                // NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
                if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true)) failed++;
            }
            else
            {
                // Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
                // historian tagname HERE (default-vs-override): a null/blank override falls back to the
                // driver-side FullName; null means the tag is not historized at all.
                string? historianTagname = tag.IsHistorized
                    ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
                    : null;
                // Array writes are out of scope (Phase 4c read-only surface): force array tags read-only
                // even if authored ReadWrite, so a client write cannot reach the driver write path which
                // does not handle arrays (e.g. S7 BoxValueForWrite would crash).
                var writable = tag.Writable && !tag.IsArray;
                if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
            }
        }

        _logger.LogInformation(
            "AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, failed={Failed})",
            composition.EquipmentTags.Count,
            composition.EquipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
            failed);
        return failed;
    }

    /// <summary>
    ///     Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
    ///     re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
    ///     this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
    ///     Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only
    ///     value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass.
    /// </summary>
    /// <param name="equipmentRootNodeId">The equipment root node the discovered nodes hang under; the
    /// NodeAdded model-change is announced under this node.</param>
    /// <param name="folders">The discovered folders to ensure (parent-first by depth).</param>
    /// <param name="variables">The discovered variables to ensure (read-only value nodes).</param>
    /// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
    /// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1).</returns>
    public int MaterialiseDiscoveredNodes(
        string equipmentRootNodeId,
        IReadOnlyList<DiscoveredFolder> folders,
        IReadOnlyList<DiscoveredVariable> variables)
    {
        ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId);
        ArgumentNullException.ThrowIfNull(folders);
        ArgumentNullException.ThrowIfNull(variables);
        if (folders.Count == 0 && variables.Count == 0) return 0;

        var failed = 0;
        // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
        foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
            if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName)) failed++;

        foreach (var v in variables)
        {
            // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
            var writable = v.Writable && !v.IsArray;
            if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
                historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
        }

        _sink.RaiseNodesAddedModelChange(equipmentRootNodeId);

        _logger.LogInformation(
            "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
            equipmentRootNodeId, folders.Count, variables.Count, failed);
        return failed;
    }

    /// <summary>
    ///     Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
    ///     analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
    ///     ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
    ///     practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
    ///     a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
    ///     (<c>parent/Name</c>) — NOT the <see cref="EquipmentVirtualTagPlan.VirtualTagId"/> or
    ///     <see cref="EquipmentVirtualTagPlan.Expression"/> — so identically-named VirtualTags on
    ///     different equipments never collide in the sink (which keys on NodeId). Variables start
    ///     BadWaitingForInitialData; <c>VirtualTagActor</c> fills live values in a later milestone.
    ///     Idempotent (per-variable idempotency relies on the sink's own <c>EnsureVariable</c>).
    /// </summary>
    /// <param name="composition">The composition result containing the equipment VirtualTags to materialise.</param>
    /// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
    /// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
    public int MaterialiseEquipmentVirtualTags(AddressSpaceComposition composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.EquipmentVirtualTags.Count == 0) return 0;

        var failed = 0;
        // Sub-folders first — a VirtualTag's FolderPath becomes one folder UNDER its equipment folder
        // (deduped per distinct equipment+path). VirtualTags with no FolderPath hang directly under the
        // equipment folder, which MaterialiseHierarchy already created (never re-create it here).
        var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
        foreach (var v in composition.EquipmentVirtualTags)
        {
            if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
            var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
            if (!foldersCreated.Add(folderNodeId)) continue;
            if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath)) failed++;
        }

        // Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
        // Parent is the FolderPath sub-folder when set, else the equipment folder directly.
        // NOTE (H5): a VirtualTag's Historize flag is honoured on the WRITE side only — VirtualTagHostActor
        // forwards historized results to IHistoryWriter. It is intentionally NOT materialised as an SDK
        // Historizing/HistoryRead variable here (no server-side OPC UA HistoryRead for vtags), so these
        // stay plain read-only computed-output nodes.
        foreach (var v in composition.EquipmentVirtualTags)
        {
            var parent = string.IsNullOrWhiteSpace(v.FolderPath)
                ? v.EquipmentId
                : EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
            var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
            // VirtualTags are computed outputs — read-only nodes (no inbound write).
            if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false)) failed++;
        }

        _logger.LogInformation(
            "AddressSpaceApplier: equipment virtualtags materialised (vtags={Vtags}, equipment={Equipment}, failed={Failed})",
            composition.EquipmentVirtualTags.Count,
            composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
            failed);
        return failed;
    }

    /// <summary>
    ///     T14 — materialise real OPC UA Part 9 <c>AlarmConditionState</c> nodes from a composition
    ///     snapshot. For each <b>enabled</b> <see cref="EquipmentScriptedAlarmPlan"/>, register a
    ///     condition node (keyed by its <see cref="EquipmentScriptedAlarmPlan.ScriptedAlarmId"/>, which
    ///     is the same id <c>OpcUaPublishActor.AlarmStateUpdate</c> targets) under its equipment folder.
    ///     Disabled alarms are skipped — they expose no node. Must run AFTER
    ///     <see cref="MaterialiseHierarchy"/> so the equipment folders exist. Idempotent (the sink's
    ///     <c>MaterialiseAlarmCondition</c> re-creates cleanly on re-apply).
    /// </summary>
    /// <param name="composition">The composition result containing the scripted alarms to materialise.</param>
    /// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
    /// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
    public int MaterialiseScriptedAlarms(AddressSpaceComposition composition)
    {
        ArgumentNullException.ThrowIfNull(composition);
        if (composition.EquipmentScriptedAlarms.Count == 0) return 0;

        var materialised = 0;
        var failed = 0;
        foreach (var alarm in composition.EquipmentScriptedAlarms)
        {
            if (!alarm.Enabled) continue;
            if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false)) failed++;
            materialised++;
        }

        _logger.LogInformation(
            "AddressSpaceApplier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment}, failed={Failed})",
            materialised,
            composition.EquipmentScriptedAlarms.Where(a => a.Enabled)
                .Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
            failed);
        return failed;
    }

    /// <summary>Ensure a folder node, swallowing (and Warning-logging) any sink fault. Returns <c>true</c>
    /// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
    /// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
    /// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
    private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
    {
        try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); return true; }
        catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
    }

    /// <summary>Ensure a variable node, swallowing (and Warning-logging) any sink fault. Returns <c>true</c>
    /// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
    /// count (archreview 01/S-1).</summary>
    /// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
    private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
    {
        try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); return true; }
        catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
    }

    // The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier (R2-07 T1) so the
    // whole delta classification is one pure, table-testable unit; the applier references them there (and the
    // classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
    // byte-for-byte.

    /// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
    /// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
    /// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.</summary>
    private static readonly AlarmConditionSnapshot RemovedConditionState = new(
        Active: false,
        Acknowledged: true,
        Confirmed: true,
        Enabled: true,
        Shelving: AlarmShelvingKind.Unshelved,
        Severity: 0,
        Message: string.Empty);

    /// <summary>Write an alarm-condition snapshot, swallowing (and Warning-logging) any sink fault.
    /// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
    /// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
    /// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
    private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
    {
        try { _sink.WriteAlarmCondition(nodeId, state, ts); return true; }
        catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
    }

    /// <summary>Materialise an alarm-condition node, swallowing (and Warning-logging) any sink fault.
    /// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into
    /// their pass's failed-node count (archreview 01/S-1).</summary>
    /// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
    private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative)
    {
        try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); return true; }
        catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
    }
}

/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
/// <para><see cref="RebuildCalled"/> means a rebuild was ATTEMPTED; <see cref="RebuildFailed"/> means the
/// attempt threw (the sink's address space is now stale/partial). <see cref="FailedNodes"/> counts
/// swallowed per-node sink failures in <see cref="AddressSpaceApplier.Apply"/>'s own passes (the removal
/// alarm-condition writes). The <c>Materialise*</c> passes report their own failed-node tallies via their
/// <c>int</c> returns — the publish actor sums both channels so a degraded apply is operator-visible
/// (Error log + <c>otopcua.opcua.apply.failed</c> meter) instead of reported as optimistic success
/// (archreview 01/S-1). New trailing fields are defaulted so every existing construction compiles.</para></summary>
public sealed record AddressSpaceApplyOutcome(
    int RemovedNodes,
    int AddedNodes,
    int ChangedNodes,
    bool RebuildCalled,
    bool RebuildFailed = false,
    int FailedNodes = 0);
