using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;

public sealed class AddressSpaceApplierTests
{




    /// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
    /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
    /// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
    [Fact]
    public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        outcome.AddedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(0);
    }

    /// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
    /// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
    /// rebuild" pin.)</summary>
    [Fact]
    public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
                    Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        outcome.AddedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(0);
    }

    /// <summary>R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on
    /// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The
    /// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is
    /// surgical-eligible); the applier still runs the F10b surgical pass.</summary>
    [Fact]
    public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
            },
            ChangedEquipmentTags = new[]
            {
                new AddressSpacePlan.EquipmentTagDelta(
                    new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
                    new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        var call = sink.SurgicalCalls.ShouldHaveSingleItem();
        call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
        call.Writable.ShouldBeTrue();
    }

    /// <summary>R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node
    /// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is
    /// preserved through the classifier routing).</summary>
    [Fact]
    public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild()
    {
        var sink = new RecordingSink { SurgicalReturns = false };
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
            },
            ChangedEquipmentTags = new[]
            {
                new AddressSpacePlan.EquipmentTagDelta(
                    new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
                    new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();          // fell back to a full rebuild
        sink.RebuildCalls.ShouldBe(1);
        sink.SurgicalCalls.ShouldHaveSingleItem();     // the surgical update was attempted first
    }

    /// <summary>R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the
    /// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2
    /// phase pin.)</summary>
    [Fact]
    public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        var nodeId = V3NodeIds.Uns("eq-1", "Speed");
        // Terminal Bad written to the removed variable BEFORE the removal.
        sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
        sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
    }

    /// <summary>R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing
    /// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION
    /// node in place (not a value variable).</summary>
    [Fact]
    public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
                    Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
        // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
        var write = sink.AlarmWrites.ShouldHaveSingleItem();
        write.NodeId.ShouldBe(nodeId);
        (!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue();
        // Removed as a condition node, not a value variable.
        sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId));
        sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId);
    }

    /// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
    /// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
    [Fact]
    public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
            RemovedEquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        // ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag.
        sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1"));
    }

    /// <summary>R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier
    /// falls back to a full rebuild (the one-way ratchet) and attempts no further surgical removes.</summary>
    [Fact]
    public void Pure_remove_with_remove_returns_false_falls_back_to_rebuild()
    {
        var sink = new RecordingSink { RemoveReturns = false };
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
                new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        // Ratchet: stopped at the FIRST failed remove — did not attempt the second.
        sink.RemoveCalls.Count.ShouldBe(1);
    }

    /// <summary>R2-07 T11 — a PureRemove on a sink lacking the surgical capability falls back to a full
    /// rebuild (safe default), same as the F10b attribute path.</summary>
    [Fact]
    public void Pure_remove_on_non_surgical_sink_rebuilds()
    {
        var sink = new PlainRecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE
    /// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's
    /// passes. (Supersedes the T2 phase pin.)</summary>
    [Fact]
    public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
            },
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        // The removed node is torn down in place; the add is left to the publish actor's materialise passes.
        sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old")));
        outcome.AddedNodes.ShouldBe(1);
        outcome.RemovedNodes.ShouldBe(1);
    }

    /// <summary>R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the
    /// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the
    /// recreate is the publish actor's job, a fresh NodeState). No rebuild.</summary>
    [Fact]
    public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var nodeId = V3NodeIds.Uns("eq-1", "Slot");
        var plan = EmptyPlan with
        {
            // Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
    }

    /// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
    [Fact]
    public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild()
    {
        var sink = new RecordingSink { RemoveReturns = false };
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
            },
            RemovedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the
    /// driver-side <c>FullName</c> re-routes to a different point) must rebuild the address space. The planner
    /// diffs the tag into <c>ChangedEquipmentTags</c> with no Added/Removed of anything else; the applier must
    /// still drive exactly one rebuild so the running server drops the stale node and re-materialises it.
    /// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path
    /// instead; those are covered by the F10b + FB-7 surgical tests.)</summary>
    [Fact]
    public void Changed_equipment_tags_only_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        // Same tag id, but the driver-side FullName flipped — a non-surgical change, so the applier rebuilds.
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: ONLY ChangedEquipmentTags is populated.
        plan.ChangedEquipmentTags.Count.ShouldBe(1);
        plan.AddedEquipmentTags.ShouldBeEmpty();
        plan.RemovedEquipmentTags.ShouldBeEmpty();
        plan.AddedEquipment.ShouldBeEmpty();
        plan.RemovedEquipment.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        outcome.ChangedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's <c>Expression</c>
    /// must SKIP the address-space rebuild. The vtag's materialised node is derived only from
    /// {EquipmentId, FolderPath, Name, DataType}, so an Expression-only edit leaves a byte-identical node;
    /// the vtag engine adopts the new expression via <c>VirtualTagHostActor</c>'s independent respawn, not
    /// the address-space path. Skipping the rebuild preserves every client's server-wide subscriptions.
    /// The edit STILL counts as a change (ChangedNodes == 1) — it just no longer forces a rebuild.
    /// (Supersedes the former H1a "expression edit ⇒ rebuild" behavior.)</summary>
    [Fact]
    public void Changed_virtual_tag_expression_only_skips_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
        // Same VirtualTag id, edited expression — the planner classifies this as a change.
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: ONLY ChangedEquipmentVirtualTags is populated.
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
        plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
        plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);   // NO RebuildAddressSpace — subscriptions preserved
        outcome.ChangedNodes.ShouldBe(1); // the edit is still tallied as a change
    }

    /// <summary>F10b — a vtag delta differing ONLY in <c>DependencyRefs</c> (e.g. a new <c>ctx.GetTag</c>
    /// literal in the script) is node-irrelevant and SKIPS the rebuild; the engine respawn picks up the
    /// new dependency set.</summary>
    [Fact]
    public void Changed_virtual_tag_dependency_refs_only_skips_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
        // Same node-relevant fields; only the dependency set differs.
        // Note: DependencyRefs normally tracks the Expression (derived by the ref extractor), so a
        // deps-only divergence is an edge case — e.g. the extractor logic changed between two deploys —
        // but it is still correctly node-irrelevant: the materialised OPC UA node is unchanged.
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a", "b" }));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        outcome.ChangedNodes.ShouldBe(1);
    }

    /// <summary>F10b — a vtag delta differing ONLY in <c>Historize</c> (write-side flag, not materialised
    /// on the node) is node-irrelevant and SKIPS the rebuild.</summary>
    [Fact]
    public void Changed_virtual_tag_historize_only_skips_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: false));
        // Same node-relevant fields; only the Historize flag flips.
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: true));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        outcome.ChangedNodes.ShouldBe(1);
    }

    /// <summary>F10b safe-default — a vtag delta where the node-affecting <c>DataType</c> ALSO changed is
    /// NOT node-irrelevant: the materialised node would differ, so the applier must still rebuild. Pins
    /// the whitelist (Expression/DependencyRefs/Historize) against accidentally swallowing a DataType
    /// edit.</summary>
    [Fact]
    public void Changed_virtual_tag_data_type_change_still_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
        // DataType flips AND the expression changes — DataType is node-affecting, so this must rebuild.
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Int32",
                Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>F10b safe-default — a vtag delta where the node-affecting <c>Name</c> changed must
    /// rebuild (the node's BrowseName/NodeId derive from Name).</summary>
    [Fact]
    public void Changed_virtual_tag_name_change_still_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "EfficiencyPct", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>F10b safe-default — a vtag delta where the node-affecting <c>FolderPath</c> changed must
    /// rebuild (the folder-scoped NodeId derives from FolderPath).</summary>
    [Fact]
    public void Changed_virtual_tag_folder_path_change_still_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "Calc", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>F10b — the skip is ONLY for a node-irrelevant vtag edit that is the SOLE change. A
    /// node-irrelevant Expression-only vtag edit MIXED with another NON-surgical change (here a changed
    /// equipment tag whose driver-side <c>FullName</c> re-routes) must still rebuild — the rebuild is forced
    /// by the OTHER change, and the running server gets its single rebuild as before.</summary>
    [Fact]
    public void Node_irrelevant_vtag_edit_mixed_with_another_change_still_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
            EquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                    Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }),
            },
        };
        // Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route).
        var next = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
            },
            EquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                    Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }),
            },
        };

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Both a node-irrelevant vtag change AND a node-affecting tag change are present.
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>F10b safe-default — a vtag delta where the node-affecting <c>EquipmentId</c> changed
    /// (admin reassigns the vtag to a different equipment) must rebuild. The materialised node lives
    /// under the old equipment folder; moving it to another folder requires a full address-space
    /// rebuild. EquipmentId is NOT in the node-irrelevant whitelist (Expression/DependencyRefs/Historize),
    /// so the applier must not skip.</summary>
    [Fact]
    public void Changed_virtual_tag_equipment_id_triggers_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
        // Same VirtualTagId, identical Expression/DependencyRefs/DataType/Name/FolderPath —
        // but reassigned to a different equipment ("eq-2" instead of "eq-1").
        var next = CompositionWithVirtualTags(
            new EquipmentVirtualTagPlan("vt-1", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: the planner sees this as a changed vtag (same id, different EquipmentId).
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
        plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
        plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        // EquipmentId is node-affecting — the node moves to a different folder — so rebuild is required.
        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        outcome.ChangedNodes.ShouldBe(1);
    }

    /// <summary>F10b safe-default — when a plan contains TWO changed vtags and ONE is node-irrelevant
    /// (Expression-only) while the OTHER is structural (DataType changed), the applier must still rebuild.
    /// The <c>Any(d =&gt; !VtagDeltaIsNodeIrrelevant(d))</c> predicate must catch the structural delta
    /// even though its sibling vtag would individually qualify for the skip.</summary>
    [Fact]
    public void Changed_virtual_tags_one_irrelevant_one_structural_triggers_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentVirtualTags = new[]
            {
                // vt-A: will receive an Expression-only edit (node-irrelevant).
                new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
                    Expression: "ctx.GetTag(\"speed\") * 60", DependencyRefs: new[] { "speed" }),
                // vt-B: will receive a DataType change (structural / node-affecting).
                new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Float64",
                    Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
            },
        };
        var next = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentVirtualTags = new[]
            {
                // vt-A: Expression-only change — individually node-irrelevant.
                new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64",
                    Expression: "ctx.GetTag(\"speed\") * 120", DependencyRefs: new[] { "speed" }),
                // vt-B: DataType flipped — structurally node-affecting.
                new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Int32",
                    Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }),
            },
        };

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: both vtags are diffed as changed, nothing else.
        plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
        plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
        plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
        plan.ChangedEquipmentTags.ShouldBeEmpty();
        plan.ChangedEquipment.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        // The structural delta on vt-B must force a rebuild even though vt-A is node-irrelevant.
        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        outcome.ChangedNodes.ShouldBe(2);
    }

    /// <summary>H1a — a deploy that ONLY edits an existing scripted alarm (here its message template)
    /// must rebuild the address space. The planner diffs the thin <c>ScriptedAlarmPlan</c> projection
    /// into <c>ChangedAlarms</c> alone; the applier must drive exactly one rebuild so the condition node
    /// reflects the edit.</summary>
    [Fact]
    public void Changed_alarms_only_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp high"));
        // Same alarm id, edited message template — the planner classifies this as a change.
        var next = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp critically high"));

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: ONLY ChangedAlarms is populated.
        plan.ChangedAlarms.Count.ShouldBe(1);
        plan.AddedAlarms.ShouldBeEmpty();
        plan.RemovedAlarms.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        outcome.ChangedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>H1a guard — a deploy that ONLY changes a driver instance's config must NOT rebuild the
    /// address space. Driver-instance changes route through DriverHostActor's spawn-plan in Runtime, not
    /// the address-space topology, so <c>ChangedDrivers</c> is intentionally excluded from
    /// <c>needsRebuild</c>. This pins the exclusion against accidental inclusion. (The pre-existing
    /// <see cref="Driver_only_changes_do_not_trigger_address_space_rebuild"/> uses a hand-built plan;
    /// this one drives the planner end-to-end.)</summary>
    [Fact]
    public void Changed_drivers_only_do_not_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"));
        var next = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}"));

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: ONLY ChangedDrivers is populated.
        plan.ChangedDrivers.Count.ShouldBe(1);
        plan.AddedDrivers.ShouldBeEmpty();
        plan.RemovedDrivers.ShouldBeEmpty();
        plan.ChangedEquipment.ShouldBeEmpty();
        plan.ChangedAlarms.ShouldBeEmpty();
        plan.ChangedEquipmentTags.ShouldBeEmpty();
        plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        outcome.ChangedNodes.ShouldBe(1); // driver change is still tallied, just not rebuild-forcing
        sink.RebuildCalls.ShouldBe(0);
    }

    /// <summary>H1a (review follow-up) — a deploy that ONLY removes existing equipment tag / VirtualTag
    /// nodes must rebuild AND tally the removals. Removed tags/VirtualTags are plain variable nodes (no
    /// Part 9 condition to write), so before the fix they reached the rebuild path but were never added
    /// to <c>removedCount</c> — <c>AddressSpaceApplyOutcome.RemovedNodes</c> reported 0, a misleading audit
    /// entry. This pins both the rebuild and the accurate count.</summary>
    [Fact]
    public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
            EquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
                    Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
            },
        };
        var next = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: ONLY the two Removed sets are populated.
        plan.RemovedEquipmentTags.Count.ShouldBe(1);
        plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1);
        plan.AddedEquipmentTags.ShouldBeEmpty();
        plan.ChangedEquipmentTags.ShouldBeEmpty();
        plan.RemovedEquipment.ShouldBeEmpty();
        plan.RemovedAlarms.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        // R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in
        // place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted.
        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        outcome.RemovedNodes.ShouldBe(2); // both removals counted
        sink.RemoveCalls.Count.ShouldBe(2);
        sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed")));
        sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency")));
    }

    // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----

    /// <summary>F10b — a deploy that ONLY flips an existing equipment tag's <c>Writable</c> bit (a plain,
    /// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change
    /// IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateTagAttributes</c>, preserving every client's
    /// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as
    /// a change (ChangedNodes == 1).</summary>
    [Fact]
    public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        // Same TagId/identity; only Writable flips false → true.
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);
        plan.AddedEquipmentTags.ShouldBeEmpty();
        plan.RemovedEquipmentTags.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);                 // NO RebuildAddressSpace — subscriptions preserved
        var call = sink.SurgicalCalls.ShouldHaveSingleItem();
        call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
        call.Writable.ShouldBeTrue();                  // the NEW Writable value
        call.Historian.ShouldBeNull();                 // not historized
        outcome.ChangedNodes.ShouldBe(1);
    }

    /// <summary>F10b — flipping <c>IsHistorized</c> false → true (no override) updates in place with the
    /// historian tagname defaulting to <c>FullName</c>; flipping true → false updates in place with a null
    /// historian tagname. Both skip the rebuild.</summary>
    [Fact]
    public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian()
    {
        // false → true (no override) ⇒ historian defaults to FullName.
        var sinkOn = new RecordingSink();
        var applierOn = new AddressSpaceApplier(sinkOn, NullLogger<AddressSpaceApplier>.Instance);
        var planOn = AddressSpacePlanner.Compute(
            CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
                FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)),
            CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
                FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)));
        planOn.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcomeOn = applierOn.Apply(planOn);

        outcomeOn.RebuildCalled.ShouldBeFalse();
        sinkOn.RebuildCalls.ShouldBe(0);
        sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A");  // default ⇒ FullName

        // true → false ⇒ historian null.
        var sinkOff = new RecordingSink();
        var applierOff = new AddressSpaceApplier(sinkOff, NullLogger<AddressSpaceApplier>.Instance);
        var planOff = AddressSpacePlanner.Compute(
            CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
                FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)),
            CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
                FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)));
        planOff.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcomeOff = applierOff.Apply(planOff);

        outcomeOff.RebuildCalled.ShouldBeFalse();
        sinkOff.RebuildCalls.ShouldBe(0);
        sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull();  // not historized ⇒ null
    }

    /// <summary>F10b — changing ONLY the <c>HistorianTagname</c> override on an already-historized tag
    /// skips the rebuild and updates in place, passing the NEW override verbatim.</summary>
    [Fact]
    public void Changed_tag_historian_tagname_only_skips_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
                FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old"));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
                FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New"));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New");  // override verbatim
    }

    /// <summary>FB-7 — a tag delta whose <c>DataType</c> changed is now surgical-eligible: the sink swaps the
    /// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and
    /// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same
    /// in-place update applies. Subscriptions are preserved.</summary>
    [Fact]
    public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        // DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied.
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);                 // NO rebuild — subscriptions preserved
        var call = sink.SurgicalCalls.ShouldHaveSingleItem();
        call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
        call.DataType.ShouldBe("Int32");               // the NEW DataType
        call.Writable.ShouldBeTrue();                  // the NEW Writable, applied in the same call
        call.IsArray.ShouldBeFalse();
        outcome.ChangedNodes.ShouldBe(1);
    }

    /// <summary>FB-7 — a tag delta whose <c>IsArray</c> flag flips scalar → array is now surgical-eligible:
    /// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical
    /// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the
    /// surgical Writable is false even though the tag stays non-writable here.</summary>
    [Fact]
    public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
                FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
                FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        var call = sink.SurgicalCalls.ShouldHaveSingleItem();
        call.IsArray.ShouldBeTrue();                   // the NEW array shape
        call.ArrayLength.ShouldBe(16u);
        call.DataType.ShouldBe("Int16");               // element type unchanged
        call.Writable.ShouldBeFalse();                 // array tag forced read-only
    }

    /// <summary>F10b safe-default — a tag delta whose driver-side <c>FullName</c> changed is NOT
    /// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds.</summary>
    [Fact]
    public void Changed_tag_full_name_change_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        sink.SurgicalCalls.ShouldBeEmpty();
    }

    /// <summary>F10b safe-default — a tag delta whose <c>Name</c> changed is NOT surgical-eligible (the
    /// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds.</summary>
    [Fact]
    public void Changed_tag_name_change_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        sink.SurgicalCalls.ShouldBeEmpty();
    }

    /// <summary>F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT
    /// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only
    /// a rebuild can materialise. No surgical call is made.</summary>
    [Fact]
    public void Changed_tag_alarm_presence_change_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
                Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        sink.SurgicalCalls.ShouldBeEmpty();
    }

    /// <summary>F10b guard — a tag that ALREADY carries an alarm (<c>Alarm is not null</c> on BOTH previous
    /// and current, identical alarm info) is NOT surgical-eligible even when only <c>Writable</c> changes.
    /// The explicit <c>Alarm is null</c> guard in <c>TagDeltaIsSurgicalEligible</c> prevents a false
    /// positive: without it the <c>with { Writable = … }</c> override (which does NOT touch <c>Alarm</c>)
    /// would leave both sides with equal <c>Alarm</c> values and the delta would WRONGLY look surgical.
    /// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it
    /// must go through a full rebuild.</summary>
    [Fact]
    public void Changed_alarm_bearing_tag_writable_only_still_rebuilds()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        // Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node.
        var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700);
        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
                FullName: "00001", Writable: false, Alarm: alarm));
        // Only Writable flips; alarm is unchanged (and still non-null on both sides).
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
                FullName: "00001", Writable: true, Alarm: alarm));

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else.
        plan.ChangedEquipmentTags.Count.ShouldBe(1);
        plan.AddedEquipmentTags.ShouldBeEmpty();
        plan.RemovedEquipmentTags.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        // The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call.
        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
        sink.SurgicalCalls.ShouldBeEmpty();  // NO surgical write on an alarm-bearing tag
    }

    /// <summary>F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain
    /// value variables, no alarm, both changing only <c>Writable</c>), the applier must apply BOTH in
    /// place (two <c>UpdateTagAttributes</c> calls) without triggering any rebuild. Proves the surgical
    /// path iterates all eligible deltas rather than stopping after the first.</summary>
    [Fact]
    public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        // Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible.
        var previous = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed",    DataType: "Float",   FullName: "40001", Writable: false, Alarm: null),
                new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float",   FullName: "40002", Writable: false, Alarm: null),
            },
        };
        // Only Writable flips on both tags; everything else is identical.
        var next = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed",    DataType: "Float",   FullName: "40001", Writable: true,  Alarm: null),
                new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float",   FullName: "40002", Writable: true,  Alarm: null),
            },
        };

        var plan = AddressSpacePlanner.Compute(previous, next);

        // Guard the arrange: exactly two changed tags, nothing else.
        plan.ChangedEquipmentTags.Count.ShouldBe(2);
        plan.AddedEquipmentTags.ShouldBeEmpty();
        plan.RemovedEquipmentTags.ShouldBeEmpty();
        plan.AddedEquipment.ShouldBeEmpty();
        plan.RemovedEquipment.ShouldBeEmpty();

        var outcome = applier.Apply(plan);

        // No rebuild — both deltas are surgical-eligible.
        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);

        // Exactly two surgical calls — one per eligible delta.
        sink.SurgicalCalls.Count.ShouldBe(2);

        // Both expected node ids must appear (order is not guaranteed).
        var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
        var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
        sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
        sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);

        outcome.ChangedNodes.ShouldBe(2);
    }

    /// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
    /// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
    /// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07
    /// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.)</summary>
    [Fact]
    public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
            },
        };
        // Surgical-eligible Writable flip on the tag AND a brand-new equipment node.
        var next = new AddressSpaceComposition(
            new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
            },
        };

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);
        plan.AddedEquipment.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
    }

    /// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
    /// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full
    /// rebuild (safe default).</summary>
    [Fact]
    public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds()
    {
        var sink = new PlainRecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>F10b fallback — when the surgical sink reports the node MISSING
    /// (<c>UpdateTagAttributes</c> returns false), the applier falls back to a full rebuild. The surgical
    /// call is still attempted (recorded once) before the fallback fires.</summary>
    [Fact]
    public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild()
    {
        var sink = new RecordingSink { SurgicalReturns = false };
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
        var next = CompositionWithTags(
            new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.ChangedEquipmentTags.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);                 // fell back to a full rebuild
        sink.SurgicalCalls.ShouldHaveSingleItem();     // the surgical update was attempted first
    }

    // ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) -----

    /// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and
    /// apply the new DisplayName IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateFolderDisplayName</c>,
    /// preserving every client's subscriptions. Exactly one surgical folder call lands with the area's
    /// NodeId (== UnsAreaId) and the NEW display name; the rename still counts as a change.</summary>
    [Fact]
    public void Area_rename_only_skips_rebuild_and_updates_folder_in_place()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant North"));
        var next = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant South"));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.RenamedFolders.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);                 // NO RebuildAddressSpace — subscriptions preserved
        var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
        call.FolderNodeId.ShouldBe("area-1");          // folder NodeId == UnsAreaId (MaterialiseHierarchy scheme)
        call.DisplayName.ShouldBe("Plant South");      // the NEW display name
        outcome.ChangedNodes.ShouldBe(1);
    }

    /// <summary>OpcUaServer-001 — a Line rename only updates the line folder in place (NodeId == UnsLineId)
    /// and skips the rebuild.</summary>
    [Fact]
    public void Line_rename_only_skips_rebuild_and_updates_folder_in_place()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell A"));
        var next = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell B"));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.RenamedFolders.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
        call.FolderNodeId.ShouldBe("line-1");
        call.DisplayName.ShouldBe("Cell B");
    }

    /// <summary>R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added
    /// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical
    /// sink, so NO rebuild fires and exactly one folder-rename call lands. (Supersedes the pre-R2-07
    /// "rename-mixed-with-add ⇒ rebuild" pin.)</summary>
    [Fact]
    public void Folder_rename_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = new AddressSpaceComposition(
            UnsAreas: new[] { new UnsAreaProjection("area-1", "North") },
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: Array.Empty<EquipmentNode>(),
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
        // Area renamed AND a brand-new equipment node — an add no longer forces a rebuild; the rename
        // rides the in-place surgical path alongside the PureAdd.
        var next = new AddressSpaceComposition(
            UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: new[] { new EquipmentNode("eq-new", "New", "line-1") },
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.RenamedFolders.Count.ShouldBe(1);
        plan.AddedEquipment.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
        sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South"));
    }

    /// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement
    /// <see cref="ISurgicalAddressSpaceSink"/> cannot apply the in-place update, so it drives a full
    /// rebuild (safe default).</summary>
    [Fact]
    public void Folder_rename_on_non_surgical_sink_rebuilds()
    {
        var sink = new PlainRecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
        var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.RenamedFolders.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>OpcUaServer-001 fallback — when the surgical sink reports the folder MISSING
    /// (<c>UpdateFolderDisplayName</c> returns false), the applier falls back to a full rebuild. The
    /// surgical call is still attempted (recorded once) before the fallback fires.</summary>
    [Fact]
    public void Folder_rename_surgical_returning_false_falls_back_to_rebuild()
    {
        var sink = new RecordingSink { FolderRenameReturns = false };
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
        var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));

        var plan = AddressSpacePlanner.Compute(previous, next);
        plan.RenamedFolders.Count.ShouldBe(1);

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);                 // fell back to a full rebuild
        sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
    }

    // ----- R2-07 T3: pure-add NodeAdded announcements -----

    /// <summary>Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
    /// equipment id.</summary>
    [Fact]
    public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
    {
        var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
                new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
            },
        };

        applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
    }

    /// <summary>A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.</summary>
    [Fact]
    public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
    {
        var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
            },
        };

        applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
    }

    /// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
    [Fact]
    public void ComputeAddAnnouncements_added_alarm_announces_equipment()
    {
        var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") },
        };

        applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" });
    }

    /// <summary>An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own
    /// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id).</summary>
    [Fact]
    public void ComputeAddAnnouncements_added_equipment_announces_line_or_self()
    {
        var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);

        var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } };
        applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" });

        var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } };
        applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" });
    }

    /// <summary>A plan with no additions yields no announcements.</summary>
    [Fact]
    public void ComputeAddAnnouncements_empty_add_plan_yields_none()
    {
        var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
        applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty();
    }

    /// <summary>AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected
    /// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped.</summary>
    [Fact]
    public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent()
    {
        var sink = new RecordingSink();
        var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
                new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
                new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
            },
        };

        applier.AnnounceAddedNodes(plan);

        sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
            new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
    }

    private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
        new(
            areas, Array.Empty<UnsLineProjection>(), Array.Empty<EquipmentNode>(),
            Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());

    private static AddressSpaceComposition CompositionWithLines(params UnsLineProjection[] lines) =>
        new(
            Array.Empty<UnsAreaProjection>(), lines, Array.Empty<EquipmentNode>(),
            Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());

    private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
        new(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = tags,
        };

    private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
        new(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentVirtualTags = vtags,
        };

    private static AddressSpaceComposition CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) =>
        // ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms.
        new(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), alarms);

    private static AddressSpaceComposition CompositionWithDrivers(params DriverInstancePlan[] drivers) =>
        new(
            Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());

    private static AddressSpacePlan EmptyPlan => new(
        Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
        Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
        Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<AddressSpacePlan.AlarmDelta>());

    private static AddressSpacePlan WithEquipmentRemoval(params string[] ids) => new(
        AddedEquipment: Array.Empty<EquipmentNode>(),
        RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
        ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
        AddedDrivers: Array.Empty<DriverInstancePlan>(),
        RemovedDrivers: Array.Empty<DriverInstancePlan>(),
        ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
        AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
        RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
        ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());

    private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
    {
        /// <summary>Gets the queue of surgical in-place tag-attribute update calls (F10b + FB-7).</summary>
        public ConcurrentQueue<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalQueue { get; } = new();
        /// <summary>Gets the list of recorded surgical in-place tag-attribute update calls.</summary>
        public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalCalls => SurgicalQueue.ToList();
        /// <summary>When false, <see cref="UpdateTagAttributes"/> reports the node missing (returns false),
        /// driving the applier's rebuild fallback. Defaults to true (node present, update succeeds).</summary>
        public bool SurgicalReturns { get; init; } = true;

        /// <summary>Records a surgical in-place tag-attribute update; returns <see cref="SurgicalReturns"/>.</summary>
        /// <param name="variableNodeId">The variable node ID to update in place.</param>
        /// <param name="writable">The new Writable (AccessLevel) for the node.</param>
        /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
        /// <param name="dataType">The new OPC UA data type name to apply in place.</param>
        /// <param name="isArray">The new array-ness of the node.</param>
        /// <param name="arrayLength">The new 1-D array length when <paramref name="isArray"/> is true.</param>
        public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
        {
            SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
            return SurgicalReturns;
        }

        /// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
        public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new();
        /// <summary>Gets the list of recorded surgical folder display-name update calls.</summary>
        public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList();
        /// <summary>When false, <see cref="UpdateFolderDisplayName"/> reports the folder missing (returns
        /// false), driving the applier's rebuild fallback. Defaults to true.</summary>
        public bool FolderRenameReturns { get; init; } = true;

        /// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
        /// <param name="folderNodeId">The folder node ID to update in place.</param>
        /// <param name="displayName">The new display name to apply.</param>
        public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
        {
            FolderRenameQueue.Enqueue((folderNodeId, displayName));
            return FolderRenameReturns;
        }

        /// <summary>Gets the queue of surgical remove calls (kind + node id) in call order (R2-07 Phase 2).</summary>
        public ConcurrentQueue<(string Kind, string NodeId)> RemoveQueue { get; } = new();
        /// <summary>Gets the list of recorded surgical remove calls.</summary>
        public List<(string Kind, string NodeId)> RemoveCalls => RemoveQueue.ToList();
        /// <summary>When false, the Remove* members report the node missing (return false), driving the
        /// applier's rebuild fallback. Defaults to true (node present, removal succeeds).</summary>
        public bool RemoveReturns { get; init; } = true;

        /// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
        public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
        {
            RemoveQueue.Enqueue(("var", variableNodeId));
            return RemoveReturns;
        }

        /// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
        public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
        {
            RemoveQueue.Enqueue(("alarm", alarmNodeId));
            return RemoveReturns;
        }

        /// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
        public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
        {
            RemoveQueue.Enqueue(("equipment", equipmentNodeId));
            return RemoveReturns;
        }

        /// <summary>Gets the queue of alarm condition write calls.</summary>
        public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
        /// <summary>Gets the queue of folder creation calls.</summary>
        public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
        /// <summary>Gets the queue of variable creation calls.</summary>
        public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new();
        /// <summary>Gets the queue of the historian-tagname arg captured per <c>EnsureVariable</c> call,
        /// keyed by NodeId (null ⇒ that call passed not-historized).</summary>
        public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
        /// <summary>Gets the queue of the isArray/arrayLength args captured per <c>EnsureVariable</c>
        /// call, keyed by NodeId.</summary>
        public ConcurrentQueue<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayQueue { get; } = new();
        /// <summary>Gets the queue of alarm-condition materialise calls.</summary>
        public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new();
        /// <summary>Gets the number of rebuild calls made on this sink.</summary>
        public int RebuildCalls;

        /// <summary>Gets the list of recorded alarm writes.</summary>
        public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList();
        /// <summary>Gets the list of recorded folder creation calls.</summary>
        public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
        /// <summary>Gets the list of recorded variable creation calls.</summary>
        public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList();
        /// <summary>Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call.</summary>
        public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
        /// <summary>Gets the list of recorded (NodeId, isArray, arrayLength) triples captured per EnsureVariable call.</summary>
        public List<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayCalls => ArrayQueue.ToList();
        /// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
        public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();

        /// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
        public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new();
        /// <summary>Gets the list of recorded value writes.</summary>
        public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList();

        /// <summary>Records a value write (NodeId + quality).</summary>
        /// <param name="nodeId">The node ID.</param>
        /// <param name="value">The value to write.</param>
        /// <param name="quality">The OPC UA quality.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
            => ValueWriteQueue.Enqueue((nodeId, quality));
        /// <summary>Records an alarm condition write call.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</param>
        /// <param name="state">The full condition state snapshot.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
            => AlarmQueue.Enqueue((alarmNodeId, state));
        /// <summary>Records an alarm-condition materialise call.</summary>
        /// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
        /// <param name="equipmentNodeId">The equipment folder node ID.</param>
        /// <param name="displayName">The condition display name.</param>
        /// <param name="alarmType">The domain alarm type.</param>
        /// <param name="severity">The domain severity.</param>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
            => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
        /// <summary>Records a folder creation call.</summary>
        /// <param name="folderNodeId">The folder node ID.</param>
        /// <param name="parentNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
            => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
        /// <summary>Records a variable creation call.</summary>
        /// <param name="variableNodeId">The variable node ID.</param>
        /// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for the variable.</param>
        /// <param name="dataType">The OPC UA built-in type name.</param>
        /// <param name="writable">Whether the node is created read/write.</param>
        /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
        {
            VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
            HistorianQueue.Enqueue((variableNodeId, historianTagname));
            ArrayQueue.Enqueue((variableNodeId, isArray, arrayLength));
        }
        /// <summary>Records a rebuild address space call.</summary>
        public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);

        /// <summary>Gets the queue of NodeAdded model-change announcements (discovered-node injection).</summary>
        public ConcurrentQueue<string> ModelChangeQueue { get; } = new();
        /// <summary>Gets the list of recorded NodeAdded model-change announcements (discovered-node injection).</summary>
        public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
        /// <summary>Records a NodeAdded model-change announcement.</summary>
        /// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
        public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
        public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
        public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
    }

    /// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
    /// prove the F10b fallback: when the bound sink lacks the surgical capability, a surgical-eligible tag
    /// delta still drives a full <c>RebuildAddressSpace</c>.</summary>
    private sealed class PlainRecordingSink : IOpcUaAddressSpaceSink
    {
        /// <summary>Gets the number of rebuild calls made on this sink.</summary>
        public int RebuildCalls;

        /// <summary>Records a value write (no-op in this sink).</summary>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        /// <summary>No-op alarm condition write call.</summary>
        public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        /// <summary>No-op alarm-condition materialise call.</summary>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
        /// <summary>No-op folder creation call.</summary>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        /// <summary>No-op variable creation call.</summary>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
        /// <summary>Records a rebuild address space call.</summary>
        public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
        /// <summary>No-op NodeAdded model-change announcement.</summary>
        public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
        public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
    }

    private sealed class ThrowingSink : IOpcUaAddressSpaceSink
    {
        private readonly bool _throwOnAlarmWrite;
        /// <summary>Initializes a new instance of the ThrowingSink class.</summary>
        /// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
        public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }

        /// <summary>Records a value write (no-op in this sink).</summary>
        /// <param name="nodeId">The node ID.</param>
        /// <param name="value">The value to write.</param>
        /// <param name="quality">The OPC UA quality.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        /// <summary>Throws an exception if configured to do so.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</param>
        /// <param name="state">The full condition state snapshot.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        /// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
        public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
        {
            if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
        }
        /// <summary>No-op alarm-condition materialise call.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</param>
        /// <param name="equipmentNodeId">The equipment folder node ID.</param>
        /// <param name="displayName">The condition display name.</param>
        /// <param name="alarmType">The domain alarm type.</param>
        /// <param name="severity">The domain severity.</param>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
        /// <summary>No-op folder creation call.</summary>
        /// <param name="folderNodeId">The folder node ID.</param>
        /// <param name="parentNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        /// <summary>No-op variable creation call.</summary>
        /// <param name="variableNodeId">The variable node ID.</param>
        /// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for the variable.</param>
        /// <param name="dataType">The OPC UA built-in type name.</param>
        /// <param name="writable">Whether the node is created read/write.</param>
        /// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
        /// <summary>No-op rebuild address space call.</summary>
        public void RebuildAddressSpace() { }
        /// <summary>No-op NodeAdded model-change announcement.</summary>
        public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
        public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
        public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
    }
}
