using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;

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

/// <summary>
///     Covers the bounded post-connect re-discovery loop: when an <see cref="ITagDiscovery"/> driver
///     reaches Connected, <see cref="DriverInstanceActor"/> runs repeated discovery passes (FOCAS-style:
///     the FixedTree is suppressed until the driver's cache populates ~0–2s after connect) and ships each
///     pass's captured nodes to its parent as <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The
///     loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not
///     spin forever. A driver that does not implement <see cref="ITagDiscovery"/> produces no passes at all.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
    /// <summary>
    ///     A discoverable driver whose first two passes yield nothing (cache still warming) and whose third
    ///     pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty
    ///     set repeats. The final <see cref="DriverInstanceActor.DiscoveredNodesReady"/> carries the 3 nodes
    ///     and no further passes arrive — proving the loop is bounded.
    /// </summary>
    [Fact]
    public void Discovery_retries_until_set_stabilises_then_stops()
    {
        var driver = new DiscoverableStubDriver();
        var parent = CreateTestProbe();
        // Tiny interval so the bounded retry runs in well under a second (no real-time waits).
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        // Drive Connecting → Connected; the Connected entry kicks discovery.
        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4
        // (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops.
        var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
        for (var i = 0; i < 4; i++)
            msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));

        // The loop must STOP once the non-empty set has stabilised — no fifth pass.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        // Early passes were empty (FixedTree cache still populating).
        msgs[0].Nodes.Count.ShouldBe(0);
        msgs[1].Nodes.Count.ShouldBe(0);
        // The set then appears and stabilises at 3 nodes.
        msgs[2].Nodes.Count.ShouldBe(3);
        var final = msgs[^1];
        final.Nodes.Count.ShouldBe(3);
        final.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
        final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" });

        // The driver was asked exactly as many times as messages published — no extra zombie pass.
        driver.DiscoverCount.ShouldBe(4);
    }

    /// <summary>A driver that does not implement <see cref="ITagDiscovery"/> produces no discovery passes —
    /// the Connected entry's discovery kick is a no-op, so the parent receives no
    /// <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.</summary>
    [Fact]
    public void Driver_without_ITagDiscovery_produces_no_discovery()
    {
        var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
        AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));

        // No discovery capability ⇒ never any DiscoveredNodesReady to the parent.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
    }

    /// <summary>
    ///     Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a
    ///     <see cref="DriverInstanceActor.ForceReconnect"/> drives the actor through Reconnecting and
    ///     back to Connected (via the auto-retry timer, the same path the existing reconnect tests use),
    ///     and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's
    ///     capabilities changed across the reconnect. The new init bumps the generation, so any
    ///     pre-reconnect tick is discarded by the generation guard (the initial loop has already settled
    ///     here, so none are in flight).
    /// </summary>
    [Fact]
    public void Discovery_reruns_after_reconnect()
    {
        var driver = new DiscoverableStubDriver();
        var parent = CreateTestProbe();
        // Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver,
            reconnectInterval: TimeSpan.FromMilliseconds(50),
            rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped.
        for (var i = 0; i < 4; i++)
            parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
        var passesBeforeReconnect = driver.DiscoverCount; // 4

        // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
        actor.Tell(new DriverInstanceActor.ForceReconnect());

        // A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees
        // the stable 3-node set immediately.
        var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
        afterReconnect.Nodes.Count.ShouldBe(3);
        afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);

        // The driver was discovered again — proves a fresh loop ran, not a replay of the old one.
        driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect);
    }

    /// <summary>
    ///     Regression for the Critical: a driver whose <c>DiscoverAsync</c> completes ASYNCHRONOUSLY (off the
    ///     actor thread) must still ship <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The handler
    ///     touches <c>Context.Parent</c> + <c>Timers</c> AFTER awaiting discovery; if it awaited with
    ///     <c>ConfigureAwait(false)</c> the continuation would resume off the actor context and those calls
    ///     would throw <c>NotSupportedException("no active ActorContext")</c> — the handler would fault and no
    ///     message would arrive. Synchronous (<c>Task.CompletedTask</c>) stubs mask the bug; this one forces a
    ///     genuine off-context resume (modelled on <c>SubscribableStubDriver.UnsubscribeYields</c>).
    /// </summary>
    [Fact]
    public void Async_completing_discovery_resumes_on_actor_context_and_publishes()
    {
        var driver = new YieldingDiscoverableStubDriver();
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets
        // a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out.
        var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
        published.Nodes.Count.ShouldBe(3);
        published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
    }

    /// <summary>
    ///     Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the
    ///     REAL <see cref="CapabilityInvoker"/> — whose Polly pipeline is awaited with
    ///     <c>ConfigureAwait(false)</c> internally. This is the one path the pass-through
    ///     <c>NullDriverCapabilityInvoker</c> (used by the sibling test) cannot cover: it returns the
    ///     call-site task directly, so it never exercises the invoker's internal off-context await. If that
    ///     internal <c>ConfigureAwait(false)</c> leaked to the actor's own <c>await _invoker.ExecuteAsync(…)</c>,
    ///     the post-await <c>Context.Parent.Tell</c> in <c>HandleRediscoverAsync</c> would fault with
    ///     "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives
    ///     the real resilience pipeline.
    /// </summary>
    [Fact]
    public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context()
    {
        var pipelineBuilder = new DriverResiliencePipelineBuilder();   // real Polly pipeline, tier-A defaults
        var invoker = new CapabilityInvoker(
            pipelineBuilder,
            driverInstanceId: "yielding-discoverable",
            optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A },
            driverType: "Stub");
        var driver = new YieldingDiscoverableStubDriver();
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(5));
        published.Nodes.Count.ShouldBe(3);
        published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
    }

    /// <summary>
    ///     The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING
    ///     (1,2,3,…) never repeats its signature, so the loop is stopped only by
    ///     <c>rediscoverMaxAttempts</c>. With a cap of 3, exactly 3 passes are published, then the stream stops.
    /// </summary>
    [Fact]
    public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap()
    {
        var driver = new GrowingDiscoverableStubDriver();
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
        for (var i = 0; i < 3; i++)
            msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));

        // Cap reached — no fourth pass even though the set never stabilised.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));

        // The set genuinely kept growing across the capped passes (1,2,3 nodes).
        msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 });
        driver.DiscoverCount.ShouldBe(3);
    }

    /// <summary>
    ///     A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
    ///     <see cref="DiscoveryRediscoverPolicy.Never"/> opts out of post-connect discovery entirely: the
    ///     Connected entry's discovery kick returns before scheduling the first tick, so the driver is never
    ///     asked to discover and the parent receives no <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.
    /// </summary>
    [Fact]
    public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing()
    {
        var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
        // Connect happened (the discovery decision is made on the Connected entry)...
        AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));

        // ...but policy=Never ⇒ no discovery pass is ever run and nothing is published.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        driver.DiscoverCount.ShouldBe(0);
    }

    /// <summary>
    ///     A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
    ///     <see cref="DiscoveryRediscoverPolicy.Once"/> runs EXACTLY one post-connect pass even when its
    ///     discovered set would keep growing forever — under <c>UntilStable</c> the never-repeating signature
    ///     would retry to the attempt cap. Exactly one <see cref="DriverInstanceActor.DiscoveredNodesReady"/>
    ///     is published and no further <c>RediscoverTick</c> is scheduled.
    /// </summary>
    [Fact]
    public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing()
    {
        var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // Exactly one pass is published (the first, growing set → 1 node)...
        var only = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
        only.Nodes.Count.ShouldBe(1);
        only.DriverInstanceId.ShouldBe(driver.DriverInstanceId);

        // ...and NO second tick is scheduled, even though the set would keep growing under UntilStable.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        driver.DiscoverCount.ShouldBe(1);
    }

    /// <summary>
    ///     <see cref="DiscoveryRediscoverPolicy.Once"/> means one pass PER (re)connect cycle — not one pass
    ///     ever. After the initial single pass settles, a <see cref="DriverInstanceActor.ForceReconnect"/>
    ///     drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and
    ///     <c>StartDiscovery</c> re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt
    ///     cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the
    ///     never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves
    ///     <c>Once</c> is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path
    ///     the follow-on TriggerRediscovery task touches.
    /// </summary>
    [Fact]
    public void Discovery_policy_Once_reruns_one_pass_on_reconnect()
    {
        var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
        var parent = CreateTestProbe();
        // Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
        // visibly more than the one pass Once must run per (re)connect.
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver,
            reconnectInterval: TimeSpan.FromMilliseconds(50),
            rediscoverInterval: TimeSpan.FromMilliseconds(20),
            rediscoverMaxAttempts: 3));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more.
        var first = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
        first.Nodes.Count.ShouldBe(1);
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
        driver.DiscoverCount.ShouldBe(1);

        // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
        actor.Tell(new DriverInstanceActor.ForceReconnect());

        // Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap.
        // The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes.
        var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
        afterReconnect.Nodes.Count.ShouldBe(2);
        afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);

        // No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        driver.DiscoverCount.ShouldBe(2);
    }

    /// <summary>
    ///     The per-pass discovery timeout is injectable via <see cref="DriverInstanceActor.Props"/> so tests
    ///     can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving).
    ///     Wiring is verified by constructing via <c>Props</c> with a custom value and confirming the actor starts
    ///     and begins discovery normally.
    /// </summary>
    [Fact]
    public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value()
    {
        // The constant must exist and preserve the pre-refactor 30 s literal.
        DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30));

        // Props must accept the new optional parameter — no throw and actor starts normally.
        var driver = new DiscoverableStubDriver();
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver,
            rediscoverInterval: TimeSpan.FromMilliseconds(20),
            rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // Actor starts and discovery publishes — confirms the custom timeout was wired without error.
        parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
    }

    /// <summary>
    ///     <see cref="DriverInstanceActor.TriggerRediscovery"/> received while Connected re-kicks the
    ///     post-connect discovery loop: after the initial discovery has settled, sending the message drives a
    ///     FRESH discovery pass — the driver's <c>DiscoverCount</c> advances and a new
    ///     <see cref="DriverInstanceActor.DiscoveredNodesReady"/> is published. This is the message
    ///     <see cref="DriverHostActor"/> uses to re-run discovery after rebinding the driver to a new equipment.
    /// </summary>
    [Fact]
    public void TriggerRediscovery_when_Connected_reruns_discovery()
    {
        // Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely
        // one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg
        // (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
        var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));

        // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles.
        parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
        var passesBeforeTrigger = driver.DiscoverCount; // 1

        // Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes).
        actor.Tell(new DriverInstanceActor.TriggerRediscovery());

        var afterTrigger = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
        afterTrigger.Nodes.Count.ShouldBe(2);
        afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId);

        // Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived.
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1);
    }

    /// <summary>
    ///     <see cref="DriverInstanceActor.TriggerRediscovery"/> on a driver whose
    ///     <see cref="ITagDiscovery.RediscoverPolicy"/> is <see cref="DiscoveryRediscoverPolicy.Never"/> does
    ///     NOT re-discover: the handler calls <c>StartDiscovery</c>, which returns early for <c>Never</c>, so
    ///     no pass runs and nothing is published — mirroring the Connected-entry Never opt-out.
    /// </summary>
    [Fact]
    public void TriggerRediscovery_with_policy_Never_does_not_rediscover()
    {
        var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));

        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
        AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));

        // Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return.
        actor.Tell(new DriverInstanceActor.TriggerRediscovery());

        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        driver.DiscoverCount.ShouldBe(0);
    }

    /// <summary>
    ///     <see cref="DriverInstanceActor.TriggerRediscovery"/> received while NOT Connected is a clean silent
    ///     no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor
    ///     neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both <c>Connecting</c>
    ///     (before init completes) and <c>Reconnecting</c> (after a <see cref="DriverInstanceActor.ForceReconnect"/>,
    ///     parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed.
    /// </summary>
    [Fact]
    public void TriggerRediscovery_when_not_Connected_is_a_silent_noop()
    {
        // Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state);
        // a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
        var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
        var parent = CreateTestProbe();
        var actor = parent.ChildActorOf(DriverInstanceActor.Props(
            driver,
            reconnectInterval: TimeSpan.FromSeconds(30),
            rediscoverInterval: TimeSpan.FromMilliseconds(20)));
        Watch(actor);

        // (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it
        // is handled in that non-Connected state.
        actor.Tell(new DriverInstanceActor.TriggerRediscovery());

        // No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor).
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
        ExpectNoMsg(TimeSpan.FromMilliseconds(100));
        driver.DiscoverCount.ShouldBe(0);

        // Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass.
        actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
        parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
        var passesAfterConnect = driver.DiscoverCount; // 1

        // (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto
        // reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both
        // messages are processed in order, so the trigger is handled while Reconnecting.
        actor.Tell(new DriverInstanceActor.ForceReconnect());
        actor.Tell(new DriverInstanceActor.TriggerRediscovery());

        parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
        ExpectNoMsg(TimeSpan.FromMilliseconds(100));            // still alive — no Terminated
        driver.DiscoverCount.ShouldBe(passesAfterConnect);     // no fresh pass while Reconnecting
    }

    /// <summary>
    ///     A <see cref="StubDriver"/> that also exposes <see cref="ITagDiscovery"/>. Each <c>DiscoverAsync</c>
    ///     pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set —
    ///     modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put.
    /// </summary>
    private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery
    {
        private int _passCount;

        /// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
        /// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
        /// existing UntilStable tests are unaffected.</summary>
        public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
            => RediscoverPolicy = policy;

        /// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
        public DiscoveryRediscoverPolicy RediscoverPolicy { get; }

        /// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
        public int DiscoverCount => Volatile.Read(ref _passCount);

        /// <summary>Streams a growing-then-stable node set into the builder (0,0,3,3,…).</summary>
        public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
        {
            var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
            var count = pass >= 3 ? 3 : 0;
            var fixedTree = builder.Folder("FixedTree", "FixedTree");
            for (var i = 0; i < count; i++)
            {
                fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
                    FullName: $"m.fixed.v{i}",
                    DriverDataType: DriverDataType.Float64,
                    IsArray: false,
                    ArrayDim: null,
                    SecurityClass: SecurityClassification.ViewOnly,
                    IsHistorized: false));
            }
            return Task.CompletedTask;
        }
    }

    /// <summary>
    ///     A discoverable driver whose <c>DiscoverAsync</c> genuinely SUSPENDS and resumes on a fresh
    ///     thread-pool thread that carries NO Akka actor cell — modelled on
    ///     <c>SubscribableStubDriver.UnsubscribeYields</c>. This forces the actor's <c>await DiscoverAsync(...)</c>
    ///     continuation to resume off-context unless the handler omits <c>ConfigureAwait(false)</c>, so it is a
    ///     deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass.
    /// </summary>
    private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery
    {
        /// <summary>Suspends on a TCS completed from a background thread, then streams 3 nodes.</summary>
        public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
        {
            var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            _ = Task.Run(() => tcs.SetResult(), cancellationToken);
            await tcs.Task.ConfigureAwait(false);   // resume on a clean thread-pool thread (no actor cell)
            var fixedTree = builder.Folder("FixedTree", "FixedTree");
            for (var i = 0; i < 3; i++)
            {
                fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
                    FullName: $"m.fixed.v{i}",
                    DriverDataType: DriverDataType.Float64,
                    IsArray: false,
                    ArrayDim: null,
                    SecurityClass: SecurityClassification.ViewOnly,
                    IsHistorized: false));
            }
        }
    }

    /// <summary>
    ///     A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the
    ///     full-reference signature differs every pass and the loop can only be bounded by the attempt cap.
    /// </summary>
    private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery
    {
        private int _passCount;

        /// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
        /// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
        /// existing attempt-cap test is unaffected. With <see cref="DiscoveryRediscoverPolicy.Once"/> the
        /// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying).</summary>
        public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
            => RediscoverPolicy = policy;

        /// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
        public DiscoveryRediscoverPolicy RediscoverPolicy { get; }

        /// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
        public int DiscoverCount => Volatile.Read(ref _passCount);

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