using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;

namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;

/// <summary>
///     End-to-end smoke tests against a live TwinCAT 3 XAR runtime. Skipped via
///     <see cref="TwinCATFactAttribute"/> when the VM isn't reachable / the AmsNetId
///     isn't set. Proves the driver's AMS route setup, ADS read/write, symbol browse,
///     native <c>AddDeviceNotification</c> subscription, array addressing, auto-reconnect,
///     full primitive type mapping, and the DiscoverAsync→address-space pipeline all work
///     on the wire — coverage the <c>FakeTwinCATClient</c>-backed unit suite can only
///     contract-test.
/// </summary>
/// <remarks>
///     <para><b>Required VM project state</b> (see <c>TwinCatProject/README.md</c>):</para>
///     <list type="bullet">
///       <item>GVL <c>GVL_Fixture</c> with <c>nCounter : DINT</c> (seed <c>1234</c>),
///         <c>rSetpoint : REAL</c> (scratch; smoke writes + reads), <c>bFlag : BOOL</c>
///         (seed <c>TRUE</c>).</item>
///       <item>GVL <c>GVL_Primitives</c> with one of every ADS primitive at its seed value.</item>
///       <item>GVL <c>GVL_Arrays</c> with <c>aReal1D : ARRAY[0..31] OF REAL</c> (scratch; array
///         round-trip test writes element 5).</item>
///       <item>PLC program <c>MAIN</c> that increments <c>GVL_Fixture.nCounter</c>
///         every cycle (so the native-notification test can observe monotonic changes
///         without writing).</item>
///     </list>
/// </remarks>
[Collection("TwinCATXar")]
[Trait("Category", "Integration")]
[Trait("Simulator", "TwinCAT-XAR")]
public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
{
    /// <summary>Verifies that the driver reads a seeded DINT value through real ADS.</summary>
    [TwinCATFact]
    public async Task Driver_reads_seeded_DINT_through_real_ADS()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        var options = BuildOptions(sim);
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-read");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var snapshots = await drv.ReadAsync(
            ["Counter"], TestContext.Current.CancellationToken);

        snapshots.Count.ShouldBe(1);
        snapshots[0].StatusCode.ShouldBe(0u,
            "ADS read against GVL_Fixture.nCounter must succeed end-to-end");
        // Value is a DINT — we only assert the read path returned an integer. Don't
        // pin to the 1234 seed: the PlcTask may watchdog-restart and reset counters
        // to 0, and we care about end-to-end transport here, not PLC uptime.
        Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(0);
    }

    /// <summary>Verifies a write-then-read round trip on a scratch REAL variable.</summary>
    [TwinCATFact]
    public async Task Driver_write_then_read_round_trip_on_scratch_REAL()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        var options = BuildOptions(sim);
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-write");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        const float probe = 42.5f;
        var writeResults = await drv.WriteAsync(
            [new WriteRequest("Setpoint", probe)],
            TestContext.Current.CancellationToken);
        writeResults.Count.ShouldBe(1);
        writeResults[0].StatusCode.ShouldBe(0u);

        var readResults = await drv.ReadAsync(
            ["Setpoint"], TestContext.Current.CancellationToken);
        readResults.Count.ShouldBe(1);
        readResults[0].StatusCode.ShouldBe(0u);
        Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f);
    }

    /// <summary>Verifies that the driver receives native ADS notifications on counter value changes.</summary>
    [TwinCATFact]
    public async Task Driver_subscribe_receives_native_ADS_notifications_on_counter_changes()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        var options = BuildOptions(sim);
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-sub");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var observed = new List<DataChangeEventArgs>();
        var gate = new SemaphoreSlim(0);
        drv.OnDataChange += (_, e) =>
        {
            lock (observed) observed.Add(e);
            gate.Release();
        };

        var handle = await drv.SubscribeAsync(
            ["Counter"], TimeSpan.FromMilliseconds(500),
            TestContext.Current.CancellationToken);

        // We only assert the transport wires up and at least one notification lands.
        // Don't pin to PLC cycle time: if PlcTask watchdog-restarts or runs slower than
        // its nominal 10 ms, increments may be coarse — but the counter still changes,
        // so any reasonable window catches it. 10 s leaves headroom for transient PLC
        // restarts without turning into a test hang.
        var got = await gate.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
        got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 10 s of subscribe");

        int observedCount;
        lock (observed) observedCount = observed.Count;
        observedCount.ShouldBeGreaterThan(0);

        await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
    }

    /// <summary>Verifies that the driver browses the committed symbol hierarchy via real ADS.</summary>
    [TwinCATFact]
    public async Task Driver_browses_committed_symbol_hierarchy_via_real_ADS()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Goes straight at the wire client rather than TwinCATDriver.DiscoverAsync: the
        // discover flow funnels symbols into an IAddressSpaceBuilder, which doesn't give
        // us a flat list to assert against. BrowseSymbolsAsync is what that flow calls
        // internally, so covering it here covers the same transport path.
        using var client = new AdsTwinCATClient();
        await client.ConnectAsync(
            new TwinCATAmsAddress(sim.TargetNetId!, sim.AmsPort),
            TimeSpan.FromSeconds(5),
            TestContext.Current.CancellationToken);

        var symbols = new List<TwinCATDiscoveredSymbol>();
        await foreach (var s in client.BrowseSymbolsAsync(TestContext.Current.CancellationToken))
            symbols.Add(s);

        symbols.ShouldNotBeEmpty("BrowseSymbolsAsync must yield at least the committed GVL symbols");

        // Spot-check the smoke-test contract: GVL_Fixture.nCounter (DINT, writable).
        var counter = symbols.FirstOrDefault(s => s.InstancePath == "GVL_Fixture.nCounter");
        counter.ShouldNotBeNull("GVL_Fixture.nCounter must surface in the symbol table");
        counter.DataType.ShouldBe(TwinCATDataType.DInt);

        // Primitive coverage — every ADS primitive is committed under GVL_Primitives. Prove
        // the type mapper catches a couple of representative entries (BOOL, REAL, STRING).
        symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vBool"   && s.DataType == TwinCATDataType.Bool);
        symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vReal"   && s.DataType == TwinCATDataType.Real);
        symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vString" && s.DataType == TwinCATDataType.String);

        // Array coverage — SymbolLoaderFactory (Flat mode) expands arrays to per-element
        // paths, so at least one element of the 2-D REAL array should appear.
        symbols.ShouldContain(s => s.InstancePath.StartsWith("GVL_Arrays.aReal2D", StringComparison.Ordinal));

        // Enum / alias coverage — GVL_Enums roots one of each so we don't have to walk plants.
        symbols.ShouldContain(s => s.InstancePath == "GVL_Enums.currentAxisState");
    }

    /// <summary>Verifies that the driver round-trips array element write and read operations.</summary>
    [TwinCATFact]
    public async Task Driver_round_trips_array_element_write_and_read()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Arrays are a high-traffic PLC pattern but no earlier test hits an indexed symbol
        // end-to-end. Round-trip into GVL_Arrays.aReal1D[5] — the subscript flows through
        // TwinCATSymbolPath.TryParse → AdsSymbolName ("GVL_Arrays.aReal1D[5]") which is
        // the ADS wire syntax the SymbolLoader emits for per-element access.
        var options = BuildOptions(sim);
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-array");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        const float probe = 73.25f;
        var writeResults = await drv.WriteAsync(
            [new WriteRequest("ArrayElem", probe)],
            TestContext.Current.CancellationToken);
        writeResults.Count.ShouldBe(1);
        writeResults[0].StatusCode.ShouldBe(0u, "array-element write must succeed against aReal1D[5]");

        var readResults = await drv.ReadAsync(
            ["ArrayElem"], TestContext.Current.CancellationToken);
        readResults[0].StatusCode.ShouldBe(0u);
        Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f);
    }

    /// <summary>Verifies that the driver auto-reconnects after the underlying ADS client is disposed.</summary>
    [TwinCATFact]
    public async Task Driver_auto_reconnects_after_underlying_client_is_disposed()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Reconnect is the most operationally important durability property — ADS links
        // drop (router restarts, VM reboots, TCP resets). EnsureConnectedAsync creates a
        // fresh AdsClient when the prior one is gone, but until this test the live-wire
        // recovery path was only unit-tested against FakeTwinCATClient.
        var options = BuildOptions(sim);
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-reconnect");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var first = await drv.ReadAsync(["Counter"], TestContext.Current.CancellationToken);
        first[0].StatusCode.ShouldBe(0u);

        // Dispose the underlying client the driver is holding. Next read must reconnect
        // (via EnsureConnectedAsync → AdsClient.Connect) rather than fail.
        var hostAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
        var device = drv.GetDeviceState(hostAddress).ShouldNotBeNull("device state must exist after Initialize");
        device.DisposeClient();

        var second = await drv.ReadAsync(["Counter"], TestContext.Current.CancellationToken);
        second[0].StatusCode.ShouldBe(0u, "driver must transparently reconnect on next read");
    }

    /// <summary>Verifies that the driver reads every primitive type with correct mapping.</summary>
    /// <param name="symbolPath">The TwinCAT symbol path to read.</param>
    /// <param name="type">The TwinCAT data type of the symbol.</param>
    /// <param name="expectedValueInvariant">The expected value in culture-invariant format, or null to skip value assertion.</param>
    [TwinCATTheory]
    // vBool's expected value is null — the initial TRUE seed doesn't reliably survive cold
    // restarts on this deployment, and the point of this theory is round-tripping the type
    // mapper, not test-data seed persistence. All other primitives re-init on every boot.
    [InlineData("GVL_Primitives.vBool",      TwinCATDataType.Bool,      null)]
    [InlineData("GVL_Primitives.vSInt",      TwinCATDataType.SInt,      "-42")]
    [InlineData("GVL_Primitives.vUSInt",     TwinCATDataType.USInt,     "250")]
    [InlineData("GVL_Primitives.vInt",       TwinCATDataType.Int,       "-12345")]
    [InlineData("GVL_Primitives.vUInt",      TwinCATDataType.UInt,      "54321")]
    [InlineData("GVL_Primitives.vDInt",      TwinCATDataType.DInt,      "-1234567")]
    [InlineData("GVL_Primitives.vUDInt",     TwinCATDataType.UDInt,     "4000000000")]
    [InlineData("GVL_Primitives.vLInt",      TwinCATDataType.LInt,      "-1234567890123")]
    [InlineData("GVL_Primitives.vULInt",     TwinCATDataType.ULInt,     "12345678901234567")]
    [InlineData("GVL_Primitives.vReal",      TwinCATDataType.Real,      "3.14159")]
    [InlineData("GVL_Primitives.vLReal",     TwinCATDataType.LReal,     "2.7182818284590452")]
    [InlineData("GVL_Primitives.vString",    TwinCATDataType.String,    "Hello from TC3")]
    [InlineData("GVL_Primitives.vTime",      TwinCATDataType.Time,      null)]
    [InlineData("GVL_Primitives.vTimeOfDay", TwinCATDataType.TimeOfDay, null)]
    [InlineData("GVL_Primitives.vDate",      TwinCATDataType.Date,      null)]
    [InlineData("GVL_Primitives.vDateTime",  TwinCATDataType.DateTime,  null)]
    public async Task Driver_reads_every_primitive_type_with_correct_mapping(
        string symbolPath, TwinCATDataType type, string? expectedValueInvariant)
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // One driver per case is ~0.3 s each on the live VM — pragmatic vs a shared-driver
        // iteration pattern since the point is to exercise options/tag-mapping/ReadAsync
        // end-to-end per primitive, catching regressions like the STRING(N) mapper bug.
        var options = new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
            RawTags = TwinCATRawTags.Entries(
                new TwinCATTagDefinition(
                    Name: "Primitive",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: symbolPath,
                    DataType: type)),
            Timeout = TimeSpan.FromSeconds(5),
            Probe = new TwinCATProbeOptions { Enabled = false },
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: $"tc3-prim-{type}");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var result = await drv.ReadAsync(["Primitive"], TestContext.Current.CancellationToken);
        result[0].StatusCode.ShouldBe(0u, $"primitive read must succeed for {symbolPath} ({type})");
        result[0].Value.ShouldNotBeNull();

        // Expected-value check only where the seed is ergonomic to re-encode as a literal —
        // TIME / TOD / DATE / DT arrive as uint ticks, which is covered by the status+type
        // assertions above; adding brittle tick-math here adds no signal over that.
        if (expectedValueInvariant is not null)
        {
            switch (type)
            {
                case TwinCATDataType.Bool:
                    result[0].Value.ShouldBe(bool.Parse(expectedValueInvariant));
                    break;
                case TwinCATDataType.String:
                    result[0].Value.ShouldBe(expectedValueInvariant);
                    break;
                case TwinCATDataType.Real:
                    Convert.ToSingle(result[0].Value).ShouldBe(
                        float.Parse(expectedValueInvariant, System.Globalization.CultureInfo.InvariantCulture),
                        tolerance: 0.0001f);
                    break;
                case TwinCATDataType.LReal:
                    Convert.ToDouble(result[0].Value).ShouldBe(
                        double.Parse(expectedValueInvariant, System.Globalization.CultureInfo.InvariantCulture),
                        tolerance: 0.0000001);
                    break;
                default:
                    // Integer primitives: parse against the target CLR type the mapper chose.
                    Convert.ToInt64(result[0].Value).ShouldBe(long.Parse(expectedValueInvariant));
                    break;
            }
        }
    }

    /// <summary>Verifies that the driver reads bit-indexed BOOL values from word symbols.</summary>
    [TwinCATFact]
    public async Task Driver_reads_bit_indexed_BOOL_from_word()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // GVL_Primitives.vWord = 0xBEEF = 1011 1110 1110 1111. Bit 3 = 1 (within the low
        // nibble 'F'). Tags with bitIndex route through ExtractBit → TwinCAT.Ads supports
        // the .N bit-access suffix natively so the driver's read path relies on that.
        var options = new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
            RawTags = TwinCATRawTags.Entries(
                new TwinCATTagDefinition(
                    Name: "WordBit3",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: "GVL_Primitives.vWord.3",
                    DataType: TwinCATDataType.Bool),
                new TwinCATTagDefinition(
                    Name: "WordBit4",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: "GVL_Primitives.vWord.4",
                    DataType: TwinCATDataType.Bool)),
            Timeout = TimeSpan.FromSeconds(5),
            Probe = new TwinCATProbeOptions { Enabled = false },
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-bitbool");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var result = await drv.ReadAsync(["WordBit3", "WordBit4"], TestContext.Current.CancellationToken);

        result[0].StatusCode.ShouldBe(0u);
        result[0].Value.ShouldBe(true,   "bit 3 of 0xBEEF is set");
        result[1].StatusCode.ShouldBe(0u);
        result[1].Value.ShouldBe(false,  "bit 4 of 0xBEEF is clear");
    }

    /// <summary>Verifies that the driver reads deeply nested UDT member paths.</summary>
    [TwinCATFact]
    public async Task Driver_reads_deeply_nested_UDT_path()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // 5-level nested path into the plant hierarchy. FB_LineSim is driving the motor
        // temperature from a sine of the counter, so the value is alive but bounded.
        var options = new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
            RawTags = TwinCATRawTags.Entries(
                new TwinCATTagDefinition(
                    Name: "MotorTemp",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: "GVL_Plant.Line1.Stations[1].Axes[1].Motor.Temperature",
                    DataType: TwinCATDataType.LReal),
                new TwinCATTagDefinition(
                    Name: "MotorRunning",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: "GVL_Plant.Line1.Stations[1].Axes[1].Motor.Running",
                    DataType: TwinCATDataType.Bool)),
            Timeout = TimeSpan.FromSeconds(5),
            Probe = new TwinCATProbeOptions { Enabled = false },
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-udt");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var result = await drv.ReadAsync(["MotorTemp", "MotorRunning"], TestContext.Current.CancellationToken);

        result[0].StatusCode.ShouldBe(0u, "nested UDT LREAL path must round-trip");
        result[0].Value.ShouldBeOfType<double>();
        result[1].StatusCode.ShouldBe(0u, "nested UDT BOOL path must round-trip");
        result[1].Value.ShouldBeOfType<bool>();
    }

    /// <summary>Verifies that the driver reports errors for unknown tags, nonexistent symbols, and read-only writes.</summary>
    [TwinCATFact]
    public async Task Driver_reports_errors_for_unknown_tag_and_nonexistent_symbol_and_readonly_write()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Three negative paths in one driver instance — cheaper than spinning three drivers
        // for what are essentially status-code assertions:
        //   1. unknown tag name (not in the options map) → BadNodeIdUnknown
        //   2. known tag pointing at a nonexistent PLC symbol → ADS error mapped to non-zero
        //   3. write against a Writable=false tag → BadNotWritable
        var options = new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
            RawTags = TwinCATRawTags.Entries(
                new TwinCATTagDefinition(
                    Name: "NonexistentSymbol",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: "GVL_DoesNotExist.vGhost",
                    DataType: TwinCATDataType.DInt),
                new TwinCATTagDefinition(
                    Name: "ReadOnlyCounter",
                    DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                    SymbolPath: "GVL_Fixture.nCounter",
                    DataType: TwinCATDataType.DInt,
                    Writable: false)),
            Timeout = TimeSpan.FromSeconds(5),
            Probe = new TwinCATProbeOptions { Enabled = false },
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-errors");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        // (1) Unknown tag — never registered in options, must short-circuit before ADS.
        var unknownRead = await drv.ReadAsync(["NeverDeclared"], TestContext.Current.CancellationToken);
        unknownRead[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);

        // (2) Known tag, nonexistent PLC symbol — ADS returns SymbolNotFound, driver maps it
        //     to a non-zero OPC UA status. Don't pin to a specific code — the exact mapping
        //     is a driver-internal concern and we only care it surfaces as an error.
        var ghostRead = await drv.ReadAsync(["NonexistentSymbol"], TestContext.Current.CancellationToken);
        ghostRead[0].StatusCode.ShouldNotBe(0u, "nonexistent PLC symbol must surface as a non-Good status");

        // (3) Read-only declared tag — write must short-circuit with BadNotWritable before ADS.
        var roWrite = await drv.WriteAsync(
            [new WriteRequest("ReadOnlyCounter", 999)],
            TestContext.Current.CancellationToken);
        roWrite[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
    }

    /// <summary>Verifies that the driver routes reads per device and isolates unreachable peers.</summary>
    [TwinCATFact]
    public async Task Driver_routes_reads_per_device_and_isolates_unreachable_peers()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Two devices in one driver: the real VM + a bogus AmsNetId that won't resolve. Tags
        // routed to the real device must still succeed; tags on the unreachable device must
        // surface comms errors without poisoning the healthy one — this is the multi-device
        // routing + per-device connection isolation contract the unit suite can't prove on the wire.
        var realHost  = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
        var ghostHost = "ads://99.99.99.99.1.1:851";
        var options = new TwinCATDriverOptions
        {
            Devices =
            [
                new TwinCATDeviceOptions(realHost,  "Real-VM"),
                new TwinCATDeviceOptions(ghostHost, "Ghost-VM"),
            ],
            RawTags = TwinCATRawTags.Entries(
                new TwinCATTagDefinition(
                    Name: "RealCounter",
                    DeviceHostAddress: realHost,
                    SymbolPath: "GVL_Fixture.nCounter",
                    DataType: TwinCATDataType.DInt),
                new TwinCATTagDefinition(
                    Name: "GhostCounter",
                    DeviceHostAddress: ghostHost,
                    SymbolPath: "GVL_Fixture.nCounter",
                    DataType: TwinCATDataType.DInt)),
            // Shorter timeout so the bogus device fails fast rather than dragging the whole
            // test; the healthy read shouldn't be slowed down by a peer timeout.
            Timeout = TimeSpan.FromSeconds(2),
            Probe = new TwinCATProbeOptions { Enabled = false },
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-multidev");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var real = await drv.ReadAsync(["RealCounter"], TestContext.Current.CancellationToken);
        real[0].StatusCode.ShouldBe(0u, "healthy device read must succeed alongside unreachable peer");

        var ghost = await drv.ReadAsync(["GhostCounter"], TestContext.Current.CancellationToken);
        ghost[0].StatusCode.ShouldNotBe(0u, "unreachable device read must surface non-Good status");

        // Per-device host resolver — each tag's resolved host matches the device it was
        // declared against, regardless of the order reads arrive.
        drv.ResolveHost("RealCounter").ShouldBe(realHost);
        drv.ResolveHost("GhostCounter").ShouldBe(ghostHost);
    }

    /// <summary>Verifies that the probe loop raises a host status transition to Running when the target is reachable.</summary>
    [TwinCATFact]
    public async Task Probe_loop_raises_host_status_transition_to_Running_on_reachable_target()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Turn the probe loop on — InitializeAsync kicks off a background task per device
        // that calls AdsClient.ReadState. On first success the driver fires an
        // OnHostStatusChanged(Unknown|Stopped → Running). We only need to see one transition
        // to Running to prove the probe + event wiring is live.
        var options = new TwinCATDriverOptions
        {
            Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
            RawTags = [],
            Timeout = TimeSpan.FromSeconds(5),
            Probe = new TwinCATProbeOptions
            {
                Enabled = true,
                Interval = TimeSpan.FromMilliseconds(250),
                Timeout  = TimeSpan.FromSeconds(2),
            },
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-probe");

        var runningSeen = new TaskCompletionSource<bool>();
        drv.OnHostStatusChanged += (_, e) =>
        {
            if (e.NewState == HostState.Running) runningSeen.TrySetResult(true);
        };

        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        // 5 s headroom: first probe fires ~250 ms after Initialize, plus ADS connect handshake.
        var completed = await Task.WhenAny(
            runningSeen.Task,
            Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
        completed.ShouldBe(runningSeen.Task, "probe loop must raise a Running transition within 5 s");

        // Snapshot should also reflect Running — proves GetHostStatuses is in sync with the event.
        var statuses = drv.GetHostStatuses();
        statuses.Count.ShouldBe(1);
        statuses[0].State.ShouldBe(HostState.Running);
    }

    /// <summary>Verifies that DiscoverAsync renders declared tags and controller browse symbols to the address space builder.</summary>
    [TwinCATFact]
    public async Task DiscoverAsync_renders_declared_tags_and_controller_browse_hits_address_space_builder()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);

        // Hits the DiscoverAsync → IAddressSpaceBuilder pipeline the browse-only test bypasses.
        // With EnableControllerBrowse=true, the driver must (a) emit the pre-declared tag under
        // the device folder and (b) drop discovered symbols into a sibling "Discovered/" folder.
        var baseOptions = BuildOptions(sim);
        var options = new TwinCATDriverOptions
        {
            Devices = baseOptions.Devices,
            RawTags = baseOptions.RawTags,
            UseNativeNotifications = baseOptions.UseNativeNotifications,
            Timeout = baseOptions.Timeout,
            Probe = baseOptions.Probe,
            EnableControllerBrowse = true,
        };
        await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-discover");
        await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var builder = new RecordingAddressSpaceBuilder();
        await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);

        // Structural folders in the order the driver emits them: TwinCAT → device → Discovered.
        builder.FolderBrowseNames.ShouldContain("TwinCAT");
        builder.FolderBrowseNames.ShouldContain($"ads://{sim.TargetNetId}:{sim.AmsPort}");
        builder.FolderBrowseNames.ShouldContain("Discovered");

        // Pre-declared tag from TwinCATDriverOptions.Tags — always emitted regardless of browse.
        builder.Variables.ShouldContain(v => v.BrowseName == "Counter"
            && v.Info.DriverDataType == DriverDataType.Int32);

        // Controller-discovered symbol — GVL_Fixture.nCounter lands under Discovered/.
        builder.Variables.ShouldContain(v => v.BrowseName == "GVL_Fixture.nCounter"
            && v.Info.DriverDataType == DriverDataType.Int32);
    }

    private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new()
    {
        Devices = [
            new TwinCATDeviceOptions(
                HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                DeviceName: "XAR-VM"),
        ],
        RawTags = TwinCATRawTags.Entries(
            new TwinCATTagDefinition(
                Name: "Counter",
                DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                SymbolPath: "GVL_Fixture.nCounter",
                DataType: TwinCATDataType.DInt),
            new TwinCATTagDefinition(
                Name: "Setpoint",
                DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                SymbolPath: "GVL_Fixture.rSetpoint",
                DataType: TwinCATDataType.Real,
                Writable: true),
            new TwinCATTagDefinition(
                Name: "ArrayElem",
                DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
                SymbolPath: "GVL_Arrays.aReal1D[5]",
                DataType: TwinCATDataType.Real,
                Writable: true)),
        UseNativeNotifications = true,
        Timeout = TimeSpan.FromSeconds(5),
        // Disable the probe loop — the smoke tests run their own reads; a background
        // probe against GVL_Fixture.nCounter would race with them for the ADS client
        // gate + inject flakiness unrelated to the code under test.
        Probe = new TwinCATProbeOptions { Enabled = false },
    };
}

/// <summary>
///     Test double that captures every <see cref="IAddressSpaceBuilder.Folder"/> /
///     <see cref="IAddressSpaceBuilder.Variable"/> call the driver makes during
///     <c>DiscoverAsync</c>. Lets assertions inspect the resulting folder + variable tree
///     without materializing an OPC UA node manager.
/// </summary>
internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
{
    /// <summary>Gets the list of recorded folder calls.</summary>
    public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
    /// <summary>Gets the list of recorded variable calls.</summary>
    public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];

    /// <summary>Gets the browse names of all recorded folders.</summary>
    public IEnumerable<string> FolderBrowseNames => Folders.Select(f => f.BrowseName);

    /// <summary>Records a folder call and returns this builder for chaining.</summary>
    /// <param name="browseName">The browse name of the folder.</param>
    /// <param name="displayName">The display name of the folder.</param>
    /// <returns>This builder instance for method chaining.</returns>
    public IAddressSpaceBuilder Folder(string browseName, string displayName)
    {
        Folders.Add((browseName, displayName));
        return this;
    }

    /// <summary>Records a variable call and returns a handle for it.</summary>
    /// <param name="browseName">The browse name of the variable.</param>
    /// <param name="displayName">The display name of the variable.</param>
    /// <param name="info">The driver attribute information.</param>
    /// <returns>A variable handle for the recorded variable.</returns>
    public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
    {
        Variables.Add((browseName, info));
        return new Handle(info.FullName);
    }

    /// <summary>Records a property call (no-op in this test double).</summary>
    /// <param name="name">The property name.</param>
    /// <param name="type">The property data type.</param>
    /// <param name="value">The property value.</param>
    public void AddProperty(string name, DriverDataType type, object? value) { }

    private sealed class Handle(string fullRef) : IVariableHandle
    {
        /// <summary>Gets the full reference of the variable.</summary>
        public string FullReference => fullRef;
        /// <summary>Marks the variable as an alarm condition with the specified information.</summary>
        /// <param name="info">Alarm condition information.</param>
        /// <returns>An alarm condition sink for handling transitions.</returns>
        public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
    }

    private sealed class NullSink : IAlarmConditionSink
    {
        /// <summary>Handles an alarm condition transition (no-op in this test double).</summary>
        /// <param name="args">The alarm event arguments.</param>
        public void OnTransition(AlarmEventArgs args) { }
    }
}
