using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;

/// <summary>
///     Verifies the discovered-node injection wired into <see cref="DriverHostActor"/> (Task 7): when a
///     driver child reports a captured FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>,
///     the host resolves the bound equipment from the authored composition, maps the nodes under it via
///     <see cref="DiscoveredNodeMapper"/>, materialises them on the OPC UA publish side
///     (<see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>), extends the live-value routing map
///     (<c>_nodeIdByDriverRef</c>), and merges the FixedTree refs into the driver's desired subscription set
///     (<see cref="DriverInstanceActor.SetDesiredSubscriptions"/>).
///
///     <para>
///         Drives a real apply through the existing harness (same artifact shape as
///         <c>DriverHostActorLiveValueTests</c> / <c>DriverHostActorWriteRoutingTests</c>) so
///         <c>_lastComposition</c> is set and a real (non-stubbed) <see cref="DriverInstanceActor"/> child
///         is spawned for <c>d1</c>. The child is backed by the shared <see cref="SubscribableStubDriver"/>
///         (records <c>LastSubscribedRefs</c>/<c>SubscribeCount</c>, exactly as
///         <c>DriverInstanceActorTests</c> asserts) so the merged subscription is observable; the OPC UA
///         publish actor is a <see cref="Akka.TestKit.TestProbe"/> (as in
///         <c>DriverHostActorLiveValueTests</c>) so the materialise + the post-injection value route are
///         observable. There is no test seam to inject a probe AS a driver child, so this is the faithful
///         end-to-end approach the harness allows.
///     </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
{
    private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test");
    private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
    private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
    private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64));
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
    private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);

    /// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
    /// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
    /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
    /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
    /// live-value routing map was extended).</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        // One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets
        // _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode
        // carries no DriverInstanceId).
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the
        // mapper keeps it (it does not shadow an authored ref).
        var discovered = new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model",
                DisplayName: "Model",
                FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64,
                IsArray: false,
                ArrayDim: null,
                Writable: false,
                IsHistorized: false),
        };

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered));

        // (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1".
        var materialise = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        materialise.EquipmentRootNodeId.ShouldBe("EQ-1");
        materialise.Variables.Count.ShouldBe(1);
        materialise.Folders.Count.ShouldBeGreaterThan(0);
        var fixedTreeNodeId = materialise.Variables[0].NodeId;

        // (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union
        // push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both.
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.ShouldContain("ft-ref-1");
        }, duration: Timeout);

        // (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the
        // _nodeIdByDriverRef live-value map was extended by the injection.
        actor.Tell(new DriverInstanceActor.AttributeValuePublished(
            "d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts));

        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
        update.NodeId.ShouldBe(fixedTreeNodeId);
        update.Value.ShouldBe(42.0);
        update.Quality.ShouldBe(OpcUaQuality.Good);
        update.TimestampUtc.ShouldBe(Ts);
    }

    /// <summary>NEW capability (follow-up E): a driver bound to an equipment via
    /// <see cref="EquipmentNode.DriverInstanceId"/> with ZERO authored equipment tags can still graft its
    /// discovered FixedTree. The equipment is resolved from the composition's <c>EquipmentNodes</c> (not just
    /// the authored <c>EquipmentTags</c>), so a tag-less equipment receives
    /// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
    /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
    /// "no equipment/authored tags".</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        // EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1.
        var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1");

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1.
        var materialise = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        materialise.EquipmentRootNodeId.ShouldBe("EQ-1");
        materialise.Variables.Count.ShouldBe(1);
        var fixedTreeNodeId = materialise.Variables[0].NodeId;

        // (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref).
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("ft-ref-1");
        }, duration: Timeout);

        // (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended).
        actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts));
        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
        update.NodeId.ShouldBe(fixedTreeNodeId);
        update.Value.ShouldBe(42.0);
    }

    /// <summary>DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE
    /// equipment but where NONE of the candidates carries a <see cref="EquipmentNode.DeviceHost"/> has nothing
    /// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1
    /// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly
    /// the degenerate shape: no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is told.</summary>
    [Fact]
    public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        // d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip.
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"),
            (Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate).
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to
    /// a DEVICE with a distinct <see cref="EquipmentNode.DeviceHost"/>, partitions its discovered FixedTree by
    /// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose
    /// DeviceHost matches. Asserts (a) TWO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> (one per
    /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
    /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
    /// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        // d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode.
        var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
            (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
            (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        // Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared
        // NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost.
        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "H1", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
                BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
        }));

        // (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment.
        var m1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        var m2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m);
        byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true);
        byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model");
        byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run");
        var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId;
        var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId;
        // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host
        // segment and reads EQ-n/FOCAS/<leaf>/<name>. Assert the EXACT collapsed path (depth + leaf) so a
        // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
        // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
        // (a lowercase-only "h1" check would miss a leaked raw "H1").
        eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model"));
        eqANodeId.ShouldNotContain("H1");
        eqANodeId.ShouldNotContain("h1");
        eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
        eqBNodeId.ShouldNotContain("h2");

        // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("ft-h1-1");
            refs.ShouldContain("ft-h2-1");
        }, duration: Timeout);

        // (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node.
        actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts));
        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout).NodeId.ShouldBe(eqANodeId);
        actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts));
        publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout).NodeId.ShouldBe(eqBNodeId);
    }

    /// <summary>Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with
    /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
    /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
    /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
            (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
            (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
            new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
                BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
            new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" },
                BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
        }));

        // Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise.
        var m1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        var m2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        // The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash.
        actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts));
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    /// <summary>Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE,
    /// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the
    /// same set) are quiet — proving <c>ShouldWarnPartition</c>'s per-driver signature dedup. The repeat is
    /// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter
    /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on
    /// pass 1; pass 2's matched routing is short-circuited by <c>PlansRoutingEqual</c>.)</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1",
            (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"),
            (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        var discovered = new[]
        {
            new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
            new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" },
                BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
            new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" },
                BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false),
        };

        // Pass 1: the unmatched "h3" partition warns EXACTLY once.
        EventFilter.Warning(contains: "discovered device-host partition(s) skipped")
            .Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)));

        // Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous.
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);

        // Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the
        // suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings.
        EventFilter.Warning(contains: "discovered device-host partition(s) skipped")
            .Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)));
    }

    /// <summary>Guard: a <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving BEFORE any deployment
    /// is applied (<c>_lastComposition</c> still null) is ignored — nothing is materialised on the publish
    /// side (the equipment can't be resolved without a composition).</summary>
    [Fact]
    public void DiscoveredNodes_before_any_apply_are_ignored()
    {
        var db = NewInMemoryDbFactory();
        var coordinator = CreateTestProbe();
        var publish = CreateTestProbe();
        var vtHost = CreateTestProbe();

        // No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null.
        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: new SubscribingDriverFactory("Modbus"),
            localRoles: new HashSet<string> { "driver" },
            opcUaPublishActor: publish.Ref,
            virtualTagHostOverride: vtHost.Ref));

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied).
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
    /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
    /// are materialised.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new.
        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" },
                BrowseName: "speed", DisplayName: "Speed", FullReference: "40001",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed".
        var materialise = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        materialise.Variables.Count.ShouldBe(1);
        materialise.Variables[0].DisplayName.ShouldBe("Model");
    }

    /// <summary>Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver
    /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
    /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
    /// FixedTree still converges.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);

        var node1 = new DiscoveredNode(
            FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
            BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
            DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
            Writable: false, IsHistorized: false);
        var node2 = new DiscoveredNode(
            FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" },
            BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2",
            DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
            Writable: false, IsHistorized: false);

        // Pass 1: a new set ⇒ one materialise + a union re-subscribe.
        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 }));
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("ft-ref-1");
        }, duration: Timeout);
        var subscribeCountAfterFirst = factory.SubscribeCount;

        // Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe).
        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 }));
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst);

        // Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs).
        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 }));
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout).Variables.Count.ShouldBe(2);
        AwaitAssert(() =>
        {
            factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst);
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("ft-ref-1");
            refs.ShouldContain("ft-ref-2");
        }, duration: Timeout);
    }

    /// <summary>Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a
    /// redeploy. A second deployment re-runs <c>PushDesiredSubscriptions</c>, which clears the live-value
    /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected
    /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached
    /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's
    /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Discovered_nodes_survive_a_redeploy_rebuild()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        var discovered = new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        };
        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered));

        // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts.
        var materialise1 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        materialise1.EquipmentRootNodeId.ShouldBe("EQ-1");
        materialise1.Variables.Count.ShouldBe(1);
        var fixedTreeNodeId = materialise1.Variables[0].NodeId;

        // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't
        // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds
        // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // The redeploy fires a fresh RebuildAddressSpace first (drain it) ...
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);

        // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply),
        // at the SAME NodeId the first injection placed it.
        var materialise2 = publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        materialise2.EquipmentRootNodeId.ShouldBe("EQ-1");
        materialise2.Variables.Count.ShouldBe(1);
        materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId);

        // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto
        // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear().
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.ShouldContain("ft-ref-1");
        }, duration: Timeout);

        // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the
        // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()).
        actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts));
        var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
        update.NodeId.ShouldBe(fixedTreeNodeId);
        update.Value.ShouldBe(42.0);
        update.Quality.ShouldBe(OpcUaQuality.Good);
    }

    /// <summary>Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment
    /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a
    /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
    /// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // First injection materialises under EQ-1.
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);

        // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for
        // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is
        // re-told (the cached d1 plan was dropped because its equipment no longer resolves).
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the
    /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly
    /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD
    /// equipment (EQ-1), so the <c>StartsWith(equipmentId + "/")</c> guard sees they no longer match EQ-2 and
    /// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
    /// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
    /// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1).
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);

        // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still
        // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is
        // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the
        // guard's known-limitation comment describes.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached
        // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/").
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
    }

    /// <summary>Follow-up C, part 2: a CONFIG-UNCHANGED rebind must RE-TRIGGER discovery on the driver's child
    /// so the dropped FixedTree re-grafts under the NEW equipment on the next pass (rather than staying absent
    /// until the driver's next natural reconnect). Wires a REAL <see cref="ITagDiscovery"/> child (policy
    /// <see cref="DiscoveryRediscoverPolicy.Once"/>, ever-growing set) so the connect-time pass populates the
    /// per-equipment cache under EQ-1, then redeploys to rebind d1 → EQ-2 with the SAME (unchanged) DriverConfig
    /// (so <c>ReconcileDrivers</c> does NOT restart the child — exactly the config-unchanged rebind). The
    /// re-inject tail drops the stale EQ-1 entry and must send <see cref="DriverInstanceActor.TriggerRediscovery"/>
    /// to d1's child. The trigger reaching the child is observed at the host level (the only faithful seam — there
    /// is no probe-as-driver-child seam): the child re-runs discovery (its <c>DiscoverCount</c> advances past the
    /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
    /// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
    /// under the new equipment EQ-2.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
    {
        var db = NewInMemoryDbFactory();
        // A REAL ITagDiscovery child (Once policy) — so the connect-time pass populates the cache and a
        // TriggerRediscovery re-runs discovery, making the trigger observable at the host level.
        var factory = new DiscoverableSubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var coordinator = CreateTestProbe();
        var publish = CreateTestProbe();
        var vtHost = CreateTestProbe();

        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" },
            opcUaPublishActor: publish.Ref,
            virtualTagHostOverride: vtHost.Ref));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);

        // The child connects and runs its single (Once) post-connect discovery pass, which the host grafts
        // under EQ-1 — this POPULATES the per-equipment cache (_discoveredByDriver[d1] = { EQ-1: plan }).
        publish.FishForMessage<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
            m => m.EquipmentRootNodeId == "EQ-1", Timeout);
        AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout);

        // Redeploy: REBIND d1 from EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged so ReconcileDrivers
        // does NOT restart the child). The re-inject tail drops the stale EQ-1-scoped cache entry.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        // The drop must SEND DriverInstanceActor.TriggerRediscovery to d1's (still-Connected) child, which
        // re-runs discovery: DiscoverCount advances past the single connect pass — the observable proof the
        // trigger reached the child. (Pre-task: no trigger ⇒ Once already settled, child never reconnects ⇒
        // DiscoverCount stays 1 ⇒ this fails.)
        AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10));

        // ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves
        // d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as
        // { EQ-2: plan-scoped-to-EQ-2 }.
        publish.FishForMessage<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
            m => m.EquipmentRootNodeId == "EQ-2", Timeout);
        var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass)

        // Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger,
        // and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now
        // SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises
        // (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no
        // fresh discovery pass runs.
        var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC,
            (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger).
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout)
            .EquipmentRootNodeId.ShouldBe("EQ-2");
        // No further publish traffic and NO further discovery pass — the re-trigger converged.
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        factory.DiscoverCount.ShouldBe(discoverCountAfterRebind);
    }

    /// <summary>Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT
    /// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan
    /// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is
    /// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so <c>droppedAny</c>
    /// stays false and no <see cref="DriverInstanceActor.TriggerRediscovery"/> is sent. Uses the REAL
    /// <see cref="ITagDiscovery"/> child (the existing <c>ExpectNoMsg</c> drop tests use a NON-discovery driver,
    /// whose child would no-op a <c>TriggerRediscovery</c> in <c>StartDiscovery</c>'s <c>is not ITagDiscovery</c>
    /// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
    /// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
    /// pass.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void No_drop_redeploy_does_not_re_trigger_discovery()
    {
        var db = NewInMemoryDbFactory();
        var factory = new DiscoverableSubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var coordinator = CreateTestProbe();
        var publish = CreateTestProbe();
        var vtHost = CreateTestProbe();

        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" },
            opcUaPublishActor: publish.Ref,
            virtualTagHostOverride: vtHost.Ref));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);

        // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1).
        publish.FishForMessage<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
            m => m.EquipmentRootNodeId == "EQ-1", Timeout);
        AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout);

        // Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop).
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises.
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout)
            .EquipmentRootNodeId.ShouldBe("EQ-1");
        // But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no
        // further publish traffic arrives. DiscoverCount stays 1.
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
        factory.DiscoverCount.ShouldBe(1);
    }

    /// <summary>Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose
    /// plan SURVIVES a redeploy must receive EXACTLY ONE <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>
    /// for that pass — the authored∪discovered UNION the re-inject tail sends — NOT the old TWO (a bulk
    /// authored-only send that dropped the whole handle, then the tail union that re-subscribed it). Observed via
    /// the shared driver's <c>SubscribeCount</c> (each non-empty SetDesiredSubscriptions ⇒ exactly one
    /// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): the count rises by EXACTLY 1 across the
    /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only
    /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // First injection: the union subscribe (authored "40001" + discovered "ft-ref-1") lands and the cache is
        // populated (_discoveredByDriver[d1] = { EQ-1: plan }).
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.ShouldContain("ft-ref-1");
        }, duration: Timeout);
        // Let the first-injection traffic settle, then snapshot the subscribe count as the redeploy baseline.
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        var countBeforeRedeploy = factory.SubscribeCount;

        // Redeploy the SAME composition (new revision so it applies; d1 → EQ-1 unchanged ⇒ the cached plan
        // SURVIVES the re-inject tail ⇒ re-applied as a single union send).
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout); // tail survivor re-materialise

        // EXACTLY ONE SetDesiredSubscriptions this redeploy: the count rises by 1 and the set is the union (the
        // combined condition is UNSATISFIABLE under the old double-send — at count+1 the set was authored-only
        // (no "ft-ref-1"), at count+2 the count overshoots — so this fails RED before the fix).
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.ShouldContain("ft-ref-1");
            factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
        }, duration: Timeout);
        // Settle + re-confirm the count did NOT creep to +2 (the retired bulk authored-only + tail union double-send).
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
    }

    /// <summary>Follow-up D (one-send invariant — DROPPED path): a CACHED driver whose plan is FULLY DROPPED by a
    /// config-unchanged rebind (the inner map empties ⇒ the driver is removed from <c>_discoveredByDriver</c>)
    /// must still receive EXACTLY ONE <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> — the AUTHORED-ONLY
    /// fallback — so its authored subscriptions are not lost now that the bulk loop SKIPS cached drivers. The
    /// re-inject tail no longer re-applies a (now-empty) plan for it, so the fallback is the only send. Observed
    /// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
    /// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — a different message type the non-discovery
    /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.ShouldContain("ft-ref-1");
        }, duration: Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        var countBeforeRedeploy = factory.SubscribeCount;

        // Redeploy REBINDING d1 EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged ⇒ child NOT restarted).
        // The cached EQ-1-scoped plan is dropped by the rebind guard ⇒ the inner map empties ⇒ d1 is removed from
        // _discoveredByDriver ⇒ NO survivor re-apply. The fallback must send the authored-only set so "40001"
        // stays subscribed this pass.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        // No MaterialiseDiscoveredNodes — the plan was dropped, not re-grafted — so no further publish traffic.
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        // EXACTLY ONE SetDesiredSubscriptions this redeploy: the authored-only fallback. The count rises by 1 and
        // the set is "40001" only (the dropped FixedTree ref is gone).
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.ShouldNotContain("ft-ref-1");
            refs.Count.ShouldBe(1);
            factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
        }, duration: Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
    }

    /// <summary>Follow-up D (one-send invariant — NON-CACHED path): a driver that was NEVER cached (no FixedTree
    /// discovered) is unaffected by the cached-driver bulk-loop skip — it still gets EXACTLY ONE bulk
    /// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> per redeploy (the re-inject tail
    /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
    /// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        // No DiscoveredNodesReady ⇒ d1 is never cached. Wait for the initial bulk subscribe to settle, then
        // snapshot the count as the redeploy baseline.
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
        }, duration: Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        var countBeforeRedeploy = factory.SubscribeCount;

        // Redeploy SAME composition (new rev). d1 is NOT in _discoveredByDriver ⇒ the bulk loop sends it once and
        // the re-inject tail skips it.
        var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
            (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no materialise — d1 was never cached

        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("40001");
            refs.Count.ShouldBe(1);
            factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
        }, duration: Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
    }

    /// <summary>Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly
    /// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via
    /// <see cref="EquipmentNode.DriverInstanceId"/>) whose FixedTree plan is FULLY DROPPED by a rebind redeploy
    /// receives an EMPTY authored-only fallback <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>, which
    /// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
    /// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
    /// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
    [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
    public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
    {
        var db = NewInMemoryDbFactory();
        var factory = new SubscribingDriverFactory("Modbus");
        // d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags.
        var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1");

        var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);

        actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
        {
            new DiscoveredNode(
                FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
                BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
                DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
                Writable: false, IsHistorized: false),
        }));

        // First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the
        // discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }.
        publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
        AwaitAssert(() =>
        {
            var refs = factory.LastSubscribedRefs;
            refs.ShouldNotBeNull();
            refs!.ShouldContain("ft-ref-1");
        }, duration: Timeout);
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        var countBeforeRedeploy = factory.SubscribeCount;

        // Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT
        // restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed
        // from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes.
        var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1");
        actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
        // No re-materialise — the cached plan was dropped (not re-grafted).
        publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));

        // The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or
        // spurious subscribe — the bug this guards against — would increment it.)
        factory.SubscribeCount.ShouldBe(countBeforeRedeploy);
    }

    /// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the
    /// deployment, and waits for the Applied ACK so the apply (and thus <c>_lastComposition</c> + the live
    /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A
    /// VirtualTag-host probe is injected so the real host isn't spawned. The <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
    /// that lands on the publish probe during apply is drained so the test's materialise / value-update
    /// assertions see only post-apply traffic.</summary>
    private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
        IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
    {
        var coordinator = CreateTestProbe();
        var publish = CreateTestProbe();
        var vtHost = CreateTestProbe();

        var actor = Sys.ActorOf(DriverHostActor.Props(
            db, TestNode, coordinator.Ref,
            driverFactory: factory,
            localRoles: new HashSet<string> { "driver" },
            opcUaPublishActor: publish.Ref,
            virtualTagHostOverride: vtHost.Ref));

        actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
        coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);

        publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);

        return (actor, publish, coordinator);
    }

    /// <summary>
    ///     Seeds a Sealed deployment whose artifact carries the minimal arrays
    ///     <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, plus a
    ///     <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag so
    ///     a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned (mirrors
    ///     <c>DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags</c>).
    /// </summary>
    private static DeploymentId SeedDeploymentWithEquipmentTags(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
        params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
    {
        var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();

        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Namespaces = new[]
            {
                new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
            },
            DriverInstances = driverIds.Select(d => new
            {
                DriverInstanceRowId = Guid.NewGuid(),
                DriverInstanceId = d,
                Name = d,
                DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
                Enabled = true,
                DriverConfig = "{}",
                NamespaceId = "ns-eq",
            }).ToArray(),
            Tags = tags.Select((t, i) => new
            {
                TagId = $"tag-{i}",
                EquipmentId = t.Equip,
                DriverInstanceId = t.Driver,
                Name = t.Name,
                FolderPath = t.Folder,
                DataType = "Double",
                TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
            }).ToArray(),
        });

        var id = DeploymentId.NewId();
        using var ctx = db.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = rev.Value,
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
        return id;
    }

    /// <summary>
    ///     Seeds a Sealed deployment whose artifact binds an equipment to a driver via the
    ///     <c>Equipment</c> row's <c>DriverInstanceId</c> (the <see cref="EquipmentNode.DriverInstanceId"/>
    ///     projection) but carries NO authored equipment tags — so the equipment can only be resolved from
    ///     <c>EquipmentNodes</c>, not <c>EquipmentTags</c>. A <c>DriverInstances</c> row (non-Windows-only
    ///     "Modbus", Enabled) is included so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is
    ///     spawned for the driver even though it has zero tags.
    /// </summary>
    private static DeploymentId SeedDeploymentWithTagLessEquipment(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string equipmentId, string driverId)
    {
        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Namespaces = new[]
            {
                new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
            },
            DriverInstances = new[]
            {
                new
                {
                    DriverInstanceRowId = Guid.NewGuid(),
                    DriverInstanceId = driverId,
                    Name = driverId,
                    DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
                    Enabled = true,
                    DriverConfig = "{}",
                    NamespaceId = "ns-eq",
                },
            },
            // The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags.
            Equipment = new[]
            {
                new
                {
                    EquipmentId = equipmentId,
                    Name = equipmentId,
                    UnsLineId = (string?)null,
                    DriverInstanceId = driverId,
                    DeviceId = (string?)null,
                },
            },
            Tags = Array.Empty<object>(),
        });

        var id = DeploymentId.NewId();
        using var ctx = db.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = rev.Value,
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
        return id;
    }

    /// <summary>
    ///     Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a
    ///     distinct <c>Device</c> row whose <c>DeviceConfig</c> carries a <c>HostAddress</c> — so each
    ///     <see cref="EquipmentNode"/> resolves a distinct <see cref="EquipmentNode.DeviceHost"/> (the shape
    ///     the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are
    ///     resolved purely from <c>EquipmentNodes</c>. The driver row is non-Windows-only ("Modbus", Enabled)
    ///     so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
    /// </summary>
    private static DeploymentId SeedDeploymentWithMultiDeviceEquipments(
        IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string driverId,
        params (string Equip, string DeviceId, string Host)[] equipments)
    {
        var artifact = JsonSerializer.SerializeToUtf8Bytes(new
        {
            Namespaces = new[]
            {
                new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
            },
            DriverInstances = new[]
            {
                new
                {
                    DriverInstanceRowId = Guid.NewGuid(),
                    DriverInstanceId = driverId,
                    Name = driverId,
                    DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
                    Enabled = true,
                    DriverConfig = "{}",
                    NamespaceId = "ns-eq",
                },
            },
            // Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor).
            Devices = equipments.Select(e => new
            {
                DeviceId = e.DeviceId,
                DriverInstanceId = driverId,
                Name = e.DeviceId,
                DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }),
            }).ToArray(),
            // Each equipment binds to the driver AND a device — the multi-device resolution path.
            Equipment = equipments.Select(e => new
            {
                EquipmentId = e.Equip,
                Name = e.Equip,
                UnsLineId = (string?)null,
                DriverInstanceId = driverId,
                DeviceId = e.DeviceId,
            }).ToArray(),
            Tags = Array.Empty<object>(),
        });

        var id = DeploymentId.NewId();
        using var ctx = db.CreateDbContext();
        ctx.Deployments.Add(new Deployment
        {
            DeploymentId = id.Value,
            RevisionHash = rev.Value,
            Status = DeploymentStatus.Sealed,
            CreatedBy = "test",
            SealedAtUtc = DateTime.UtcNow,
            ArtifactBlob = artifact,
        });
        ctx.SaveChanges();
        return id;
    }

    /// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
    /// type, exposing its most-recent subscribed reference set for assertions (mirrors
    /// <c>DriverHostActorWriteRoutingTests.RecordingDriverFactory</c>, but the driver is
    /// <see cref="ISubscribable"/> so the merged subscription is observable).</summary>
    private sealed class SubscribingDriverFactory : IDriverFactory
    {
        private readonly string _supportedType;
        private readonly SubscribableStubDriver _driver = new();
        public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }

        /// <summary>The reference set passed to the driver's most recent <c>SubscribeAsync</c> call.</summary>
        public IReadOnlyList<string>? LastSubscribedRefs => _driver.LastSubscribedRefs;

        /// <summary>Number of <c>SubscribeAsync</c> calls so far — lets a test prove a redundant re-apply did
        /// NOT force a (drop-then-)re-subscribe of the whole handle.</summary>
        public int SubscribeCount => _driver.SubscribeCount;

        /// <inheritdoc />
        public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
            string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;

        /// <inheritdoc />
        public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
    }

    /// <summary>Factory producing a single shared <see cref="DiscoverableSubscribingDriver"/> for the supported
    /// type — a real (non-stubbed) <see cref="DriverInstanceActor"/> child that exposes <see cref="ITagDiscovery"/>,
    /// so the host's post-connect discovery loop populates the discovered-node cache AND a
    /// <see cref="DriverInstanceActor.TriggerRediscovery"/> re-runs discovery (the seam the rebind re-trigger
    /// test asserts through). Exposes the driver's pass count so a test can observe the trigger landing.</summary>
    private sealed class DiscoverableSubscribingDriverFactory : IDriverFactory
    {
        private readonly string _supportedType;
        private DiscoverableSubscribingDriver? _driver;
        public DiscoverableSubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }

        /// <summary>Number of <c>DiscoverAsync</c> passes the child has driven (advances on every
        /// connect-time pass and every <see cref="DriverInstanceActor.TriggerRediscovery"/>-driven pass).</summary>
        public int DiscoverCount => _driver?.DiscoverCount ?? 0;

        /// <inheritdoc />
        public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
            string.Equals(driverType, _supportedType, StringComparison.Ordinal)
                ? _driver ??= new DiscoverableSubscribingDriver(driverInstanceId)
                : null;

        /// <inheritdoc />
        public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
    }

    /// <summary>A <see cref="StubDriver"/> that is BOTH <see cref="ISubscribable"/> (so the host's subscribe
    /// path is exercised) and <see cref="ITagDiscovery"/> with policy <see cref="DiscoveryRediscoverPolicy.Once"/>
    /// — exactly one post-connect pass, re-runnable by <see cref="DriverInstanceActor.TriggerRediscovery"/>. Each
    /// pass streams an ever-growing FixedTree (pass N → N nodes, refs "ft-ref-1".."ft-ref-N", none shadowing the
    /// authored "40001"), so a re-triggered pass yields a grown set the host re-applies — and the public
    /// <see cref="DiscoverCount"/> makes the trigger observable at the host level. Re-implements
    /// <see cref="IDriver.DriverInstanceId"/> (the base <see cref="StubDriver"/> hardcodes "stub-driver-1") so the
    /// spawned child reports the SPEC's id — otherwise its auto-sent <see cref="DriverInstanceActor.DiscoveredNodesReady"/>
    /// would carry the wrong driver id and resolve no equipment.</summary>
    private sealed class DiscoverableSubscribingDriver : StubDriver, IDriver, ISubscribable, ITagDiscovery
    {
        private int _passCount;
        private readonly string _driverInstanceId;
        private readonly StubHandle _handle = new();

        public DiscoverableSubscribingDriver(string driverInstanceId) => _driverInstanceId = driverInstanceId;

        /// <summary>The spec's driver instance id (re-mapped from the base "stub-driver-1").</summary>
        public new string DriverInstanceId => _driverInstanceId;

        /// <summary>Single post-connect pass per (re)kick — re-runnable by TriggerRediscovery.</summary>
        public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;

        /// <summary>Number of <c>DiscoverAsync</c> passes driven so far.</summary>
        public int DiscoverCount => Volatile.Read(ref _passCount);

        /// <summary>Never raised (the test asserts on discovery + materialise, not data changes); explicit
        /// empty accessors satisfy the interface without a never-used backing field (no CS0067).</summary>
        public event EventHandler<DataChangeEventArgs>? OnDataChange { add { } remove { } }

        /// <inheritdoc />
        public Task<ISubscriptionHandle> SubscribeAsync(
            IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
            => Task.FromResult<ISubscriptionHandle>(_handle);

        /// <inheritdoc />
        public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
            => Task.CompletedTask;

        /// <summary>Streams an ever-growing FixedTree (pass N → N nodes).</summary>
        public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
        {
            var pass = Interlocked.Increment(ref _passCount);
            var fixedTree = builder.Folder("FixedTree", "FixedTree");
            for (var i = 0; i < pass; i++)
            {
                fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
                    FullName: $"ft-ref-{i + 1}",
                    DriverDataType: DriverDataType.Float64,
                    IsArray: false,
                    ArrayDim: null,
                    SecurityClass: SecurityClassification.ViewOnly,
                    IsHistorized: false));
            }
            return Task.CompletedTask;
        }
    }
}
