using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json;
using System.Xml.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     The MTConnect driver against a <b>real</b> MTConnect Agent (the <c>mtconnect/agent</c>
///     fixture in <c>Docker/</c>, or a live Agent on a machine tool via
///     <c>MTCONNECT_AGENT_ENDPOINT</c>). Everything else in the driver's test surface runs on canned
///     XML; this suite is the only thing that touches a socket.
/// </summary>
/// <remarks>
///     <para>
///         <b>What only a live Agent can prove</b>, and therefore what every test here is aimed at:
///     </para>
///     <list type="number">
///         <item>
///             <description>
///                 <b>UPPER_SNAKE type spellings.</b> A <c>/probe</c> document writes
///                 <c>DataItem@type</c> as <c>PART_COUNT</c>; a streams document names the same
///                 concept <c>PartCount</c>. An earlier revision of the inference matched only the
///                 PascalCase spelling, typed every real Agent's part counter as
///                 <see cref="DriverDataType.String"/>, and passed every unit test — because the
///                 hand-authored fixtures used the streams spelling in the probe document too.
///             </description>
///         </item>
///         <item>
///             <description>
///                 <b><c>name</c> differs from <c>id</c>.</b> On a real Agent most DataItems carry
///                 both, and they differ. The canned fixtures use ids that double as names, which is
///                 the one arrangement under which confusing the two is invisible.
///             </description>
///         </item>
///         <item>
///             <description>
///                 <b>A real <c>multipart/x-mixed-replace</c> stream.</b> The canned subscribe tests
///                 drive a fake pump that hands the driver pre-parsed chunks; real boundaries, real
///                 chunked transfer-encoding, and real heartbeats are a different thing entirely.
///             </description>
///         </item>
///     </list>
///     <para>
///         <b>Assertions are structural, never by literal id.</b> Every expectation is computed from
///         the Agent's OWN <c>/probe</c> response (<see cref="MTConnectAgentFixture.ProbeDocument"/>),
///         so the suite survives an edit to the seeded <c>Devices.xml</c> and can be pointed at a
///         real machine tool. A test whose subject the Agent does not declare (no CONDITION, no
///         PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
///     </para>
///     <para>
///         <b>Two things this suite deliberately does NOT cover, with reasons.</b>
///         (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
///         pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
///         answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
///         each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
///         shape belongs to older/third-party Agents and stays canned-only.
///         (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
///         <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
///     </para>
/// </remarks>
[Collection(MTConnectAgentCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Driver", "MTConnect")]
public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
{
    /// <summary>
    ///     Hard per-test ceiling, in milliseconds. Every test also bounds its own awaits, but a
    ///     live-integration suite must never be able to wedge a build against a half-up fixture —
    ///     an Agent that completes its TCP handshake and then never answers would otherwise hang
    ///     inside the driver's own retry loop.
    /// </summary>
    private const int TestTimeoutMs = 90_000;

    /// <summary>Canonical <c>Opc.Ua.StatusCodes</c> numerics, restated so the assertion names the wire value a client sees.</summary>
    private const uint Good = 0x00000000u;

    private const uint BadNoCommunication = 0x80310000u;

    /// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
    private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);

    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    // ---- reachability ----

    /// <summary>
    ///     The AdminUI Test Connect path against a live Agent: <see cref="MTConnectDriverProbe"/>
    ///     must go green and report a latency.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Probe_reports_the_live_agent_reachable()
    {
        SkipIfDown();

        var result = await new MTConnectDriverProbe()
            .ProbeAsync(ProbeConfig(agent.AgentUri), TimeSpan.FromSeconds(15), Ct);

        result.Ok.ShouldBeTrue($"Test Connect must go green against the live Agent. Message: {result.Message}");
        result.Message.ShouldNotBeNull();
        result.Message.ShouldContain("/probe");
        result.Latency.ShouldNotBeNull();
    }

    /// <summary>
    ///     Falsifiability control for <see cref="Probe_reports_the_live_agent_reachable"/>: the same
    ///     probe against a port nothing is listening on must go RED. Without this, a probe that
    ///     returned <c>Ok = true</c> unconditionally would satisfy the green test forever.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Probe_reports_a_dead_endpoint_as_unreachable()
    {
        SkipIfDown();

        var result = await new MTConnectDriverProbe()
            .ProbeAsync(ProbeConfig($"http://127.0.0.1:{ClosedLoopbackPort()}"), TimeSpan.FromSeconds(5), Ct);

        result.Ok.ShouldBeFalse("a probe against a closed port must fail, or the green probe proves nothing");
        result.Latency.ShouldBeNull();
    }

    // ---- discovery: the /probe device model ----

    /// <summary>
    ///     <c>DiscoverAsync</c> streams a non-empty, genuinely nested tree, and every leaf in it is
    ///     a DataItem the Agent actually declares — no invented references, and nothing dropped
    ///     except the composition-owned items the walk is documented not to visit.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Discovery_streams_the_agents_declared_device_model()
    {
        SkipIfDown();

        var capture = await DiscoverAsync();

        capture.Variables.ShouldNotBeEmpty("a live Agent declares data items; an empty browse tree is the #485 shape");
        capture.Folders.ShouldNotBeEmpty();
        capture.Folders.ShouldContain(
            f => f.ParentPath.Length > 0,
            "the walk must recurse <Components>, not just emit one folder per device");

        var declared = agent.DeclaredDataItems;
        var declaredIds = declared.Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
        var discoveredIds = capture.Variables.Select(v => v.Attr.FullName).ToHashSet(StringComparer.Ordinal);

        discoveredIds.Except(declaredIds).ShouldBeEmpty("discovery must not invent references the Agent never declared");

        var expected = declared.Where(d => !d.InComposition).Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
        expected.Except(discoveredIds).ShouldBeEmpty("every component-owned DataItem the Agent declares must be browsable");
    }

    /// <summary>
    ///     <b>The regression this whole fixture exists for.</b> An <c>EVENT</c> whose probe-document
    ///     type is the UPPER_SNAKE <c>PART_COUNT</c> / <c>LINE_NUMBER</c> must infer
    ///     <see cref="DriverDataType.Int64"/>. Matching only the streams document's PascalCase
    ///     <c>PartCount</c> types it <see cref="DriverDataType.String"/> — green in every unit test,
    ///     wrong on every real Agent.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Discovery_types_an_upper_snake_numeric_event_as_Int64()
    {
        SkipIfDown();

        var numericEvents = agent.DeclaredDataItems
            .Where(d => Is(d.Category, "EVENT"))
            .Where(d => Is(d.Type, "PART_COUNT") || Is(d.Type, "LINE_NUMBER") || Is(d.Type, "LINE"))
            .ToList();

        if (numericEvents.Count == 0)
        {
            Assert.Skip(
                $"The Agent at {agent.AgentUri} declares no PART_COUNT / LINE_NUMBER EVENT, so the " +
                "UPPER_SNAKE numeric-event inference has nothing to assert against here.");
        }

        var capture = await DiscoverAsync();

        foreach (var item in numericEvents)
        {
            var variable = Leaf(capture, item.Id);
            variable.Attr.DriverDataType.ShouldBe(
                DriverDataType.Int64,
                $"DataItem '{item.Id}' (category={item.Category}, type={item.Type}) is an integer EVENT; " +
                "typing it String is the UPPER_SNAKE-vs-PascalCase defect this suite exists to catch");
            variable.Attr.IsArray.ShouldBeFalse();
        }
    }

    /// <summary>
    ///     A <c>SAMPLE</c> is a measured quantity by definition of the category, so it must infer
    ///     <see cref="DriverDataType.Float64"/> — scalar for an ordinary sample, an array for a
    ///     <c>TIME_SERIES</c>.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Discovery_types_samples_as_Float64_and_time_series_as_a_float_array()
    {
        SkipIfDown();

        var samples = agent.DeclaredDataItems
            .Where(d => Is(d.Category, "SAMPLE") && !d.InComposition)
            .Where(d => !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
            .ToList();

        if (samples.Count == 0)
        {
            Assert.Skip($"The Agent at {agent.AgentUri} declares no scalar/time-series SAMPLE data items.");
        }

        var capture = await DiscoverAsync();

        foreach (var item in samples)
        {
            var variable = Leaf(capture, item.Id);
            variable.Attr.DriverDataType.ShouldBe(
                DriverDataType.Float64,
                $"DataItem '{item.Id}' is category=SAMPLE (type={item.Type}, units={item.Units ?? "<none>"})");

            if (Is(item.Representation, "TIME_SERIES"))
            {
                variable.Attr.IsArray.ShouldBeTrue($"'{item.Id}' declares representation=TIME_SERIES");

                // Not a bug and not an oversight: `sampleCount` is an attribute of a TIME_SERIES
                // *observation*, not of a DataItem declaration. A real Agent REJECTS the whole
                // DataItem if the declaration carries one ("The following keys were present and not
                // expected: sampleCount" -> "Invalid element 'DataItem'"), so the inference can only
                // ever see null here and a live TIME_SERIES tag is always variable-length. Asserted
                // rather than ignored so a future change that starts fabricating a dimension is loud.
                variable.Attr.ArrayDim.ShouldBeNull(
                    $"'{item.Id}': a real Agent never carries sampleCount on a DataItem declaration");
            }
            else
            {
                variable.Attr.IsArray.ShouldBeFalse($"'{item.Id}' declares no TIME_SERIES representation");
            }
        }
    }

    /// <summary>
    ///     A <c>CONDITION</c> is a state word, never a number, whatever its <c>type</c> says — and it
    ///     is the one category discovery flags as an alarm.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Discovery_types_a_condition_as_a_string_alarm()
    {
        SkipIfDown();

        var conditions = agent.DeclaredDataItems
            .Where(d => Is(d.Category, "CONDITION") && !d.InComposition)
            .ToList();

        if (conditions.Count == 0)
        {
            Assert.Skip($"The Agent at {agent.AgentUri} declares no CONDITION data items.");
        }

        var capture = await DiscoverAsync();

        foreach (var item in conditions)
        {
            var variable = Leaf(capture, item.Id);
            variable.Attr.DriverDataType.ShouldBe(DriverDataType.String, $"CONDITION '{item.Id}' reports a state word");
            variable.Attr.IsAlarm.ShouldBeTrue($"CONDITION '{item.Id}' is an alarm-bearing data item");
            variable.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
        }
    }

    /// <summary>
    ///     <b>The <c>FullName</c>-vs-browse-name split, on the shape that makes it visible.</b> The
    ///     driver-side reference must be the DataItem's <c>id</c> (the observation correlation key)
    ///     while the browse name is its <c>name</c>. Committing the name instead produces a picker
    ///     that looks perfect and authors tags that can never report a value — and it is invisible on
    ///     the canned fixtures, whose ids double as names.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs()
    {
        SkipIfDown();

        var renamed = agent.DeclaredDataItems
            .Where(d => !d.InComposition)
            .Where(d => !string.IsNullOrEmpty(d.Name) && !string.Equals(d.Name, d.Id, StringComparison.Ordinal))
            .ToList();

        if (renamed.Count == 0)
        {
            Assert.Skip(
                $"Every DataItem the Agent at {agent.AgentUri} declares has name == id (or no name), so " +
                "the id-vs-name split cannot be observed against this device model.");
        }

        var capture = await DiscoverAsync();

        foreach (var item in renamed)
        {
            var variable = Leaf(capture, item.Id);
            variable.Attr.FullName.ShouldBe(item.Id, "the driver-side reference must be the DataItem id");
            variable.BrowseName.ShouldBe(item.Name, "the browse name must prefer the DataItem name");
        }
    }

    /// <summary>
    ///     A device scope that matches nothing must fail loudly. It must never degrade to an
    ///     empty-but-successful browse — indistinguishable from a working connection to a machine
    ///     that declares nothing (#485).
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Discovery_scoped_to_an_unknown_device_fails_rather_than_browsing_empty()
    {
        SkipIfDown();

        var driver = new MTConnectDriver(
            LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}"), "mtconnect-live-unknown-device");
        var capture = new DiscoveryCapture();

        try
        {
            // Either leg may be the one that fails — the Agent answers /probe and /current for an
            // unknown device with 404 + an MTConnectError body — so the assertion is that ONE of
            // them did, and that nothing was streamed.
            var failed = false;
            try
            {
                await driver.InitializeAsync(ConfigJson(LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}")), Ct);
                await driver.DiscoverAsync(capture, Ct);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                failed = true;
            }

            failed.ShouldBeTrue("an unknown device scope must surface as a failure, never as an empty browse");
            capture.Variables.ShouldBeEmpty();
        }
        finally
        {
            await driver.ShutdownAsync(CancellationToken.None);
        }
    }

    // ---- reads: the /current snapshot ----

    /// <summary>
    ///     <c>ReadAsync</c> returns live, coerced values off the Agent's <c>/current</c> snapshot for
    ///     the numeric samples it is currently reporting.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Read_returns_live_values_for_the_agents_numeric_samples()
    {
        SkipIfDown();

        var ids = await NumericSampleIdsAsync();
        if (ids.Count == 0)
        {
            Assert.Skip(
                $"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value " +
                "(every sample is UNAVAILABLE). Is the fixture's adapter service running?");
        }

        await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));

        var results = await live.Driver.ReadAsync([.. ids], Ct);

        results.Count.ShouldBe(ids.Count);

        var good = results.Where(r => r.StatusCode == Good).ToList();
        good.ShouldNotBeEmpty("at least one numeric SAMPLE the Agent reports a value for must read Good");
        good.ShouldAllBe(r => r.Value is double);
        good.ShouldAllBe(r => r.SourceTimestampUtc != null && r.SourceTimestampUtc.Value.Kind == DateTimeKind.Utc);
    }

    /// <summary>
    ///     The Agent's literal <c>UNAVAILABLE</c> must map to <c>BadNoCommunication</c> — not to a
    ///     Good empty string, and not to the "no value yet" code, which means something different to
    ///     an operator.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Read_maps_an_unavailable_observation_to_BadNoCommunication()
    {
        SkipIfDown();

        var unavailable = (await CurrentAsync())
            .Descendants()
            .Where(e => (string?)e.Attribute("dataItemId") is { Length: > 0 })
            .Where(e => !e.HasElements && string.Equals(e.Value.Trim(), "UNAVAILABLE", StringComparison.Ordinal))
            .Select(e => (string)e.Attribute("dataItemId")!)
            .Distinct(StringComparer.Ordinal)
            .ToList();

        if (unavailable.Count == 0)
        {
            Assert.Skip(
                $"The Agent at {agent.AgentUri} is currently reporting no UNAVAILABLE observation, so the " +
                "UNAVAILABLE -> BadNoCommunication mapping has nothing live to assert against.");
        }

        await using var live = await LiveDriverAsync(unavailable.Select(id => Tag(id, DriverDataType.String)));

        var results = await live.Driver.ReadAsync([.. unavailable], Ct);

        results.Count.ShouldBe(unavailable.Count);
        results.ShouldAllBe(r => r.StatusCode == BadNoCommunication);
        results.ShouldAllBe(r => r.Value == null);
    }

    // ---- subscriptions: the /sample long poll ----

    /// <summary>
    ///     <b>The live streaming proof.</b> A real <c>multipart/x-mixed-replace</c> body, with real
    ///     boundaries and real heartbeats, must drive <c>OnDataChange</c> — and must drive it with a
    ///     value that has actually MOVED since the primed <c>/current</c> baseline. Asserting merely
    ///     that a callback arrived would be satisfied by the priming callback alone, which is
    ///     answered out of the snapshot and proves nothing about the stream.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Subscribe_delivers_a_changed_value_from_the_live_sample_stream()
    {
        SkipIfDown();

        var ids = await NumericSampleIdsAsync();
        if (ids.Count == 0)
        {
            Assert.Skip(
                $"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value, so no " +
                "observation can be expected to move. Is the fixture's adapter service running?");
        }

        await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));

        var seen = new ConcurrentQueue<DataChangeEventArgs>();
        var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var baseline = new ConcurrentDictionary<string, double>(StringComparer.Ordinal);

        live.Driver.OnDataChange += (_, e) =>
        {
            seen.Enqueue(e);
            if (e.Snapshot.StatusCode != Good || e.Snapshot.Value is not double value)
            {
                return;
            }

            // First Good value per reference is the baseline (the priming callback); a later one
            // that differs is the stream doing its job.
            if (!baseline.TryAdd(e.FullReference, value) &&
                baseline.TryGetValue(e.FullReference, out var first) &&
                Math.Abs(first - value) > 1e-9)
            {
                moved.TrySetResult();
            }
        };

        var handle = await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
        try
        {
            using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
            deadline.CancelAfter(StreamWait);

            var finished = await Task.WhenAny(moved.Task, Task.Delay(Timeout.Infinite, deadline.Token));

            (finished == moved.Task).ShouldBeTrue(
                $"the live /sample stream must deliver a CHANGED value within {StreamWait.TotalSeconds:F0}s. " +
                $"Callbacks seen: {seen.Count}; references with a Good baseline: {baseline.Count}.");

            seen.Count.ShouldBeGreaterThan(ids.Count, "more callbacks than the one priming callback per reference");
        }
        finally
        {
            await live.Driver.UnsubscribeAsync(handle, CancellationToken.None);
        }
    }

    // ---- the v3 production shape: RawPath identity ----

    /// <summary>
    ///     The shape a deployed driver actually serves: the artifact injects <c>RawTags</c>
    ///     (RawPath + a <c>TagConfig</c> blob naming the DataItem id), and the driver must read and
    ///     publish <b>by RawPath</b>. A driver that answered under its own DataItem id would be
    ///     dropped silently by <c>DriverHostActor</c>'s RawPath-keyed routing table, with every unit
    ///     test still green.
    /// </summary>
    [Fact(Timeout = TestTimeoutMs)]
    public async Task Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags()
    {
        SkipIfDown();

        var ids = (await NumericSampleIdsAsync()).Take(4).ToList();
        if (ids.Count == 0)
        {
            Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
        }

        var rawPaths = ids.ToDictionary(id => id, id => $"Plant/MTConnect/live/{id}", StringComparer.Ordinal);

        // The real merge DeploymentArtifact.TryReadSpec runs, not a hand-written JSON literal: this
        // proves the driver binds the shape the deploy artifact actually produces.
        var merged = DriverDeviceConfigMerger.Merge(
            ConfigJson(LiveOptions()),
            [new DriverDeviceConfigMerger.DeviceRow("live", "{}")],
            [.. ids.Select(id => new RawTagEntry(
                rawPaths[id],
                $$"""{"fullName":"{{id}}","driverDataType":"Float64"}""",
                WriteIdempotent: false,
                DeviceName: "live"))]);

        var driver = new MTConnectDriver(LiveOptions(), "mtconnect-live-rawtags");
        try
        {
            await driver.InitializeAsync(merged, Ct);

            var byRawPath = await driver.ReadAsync([.. rawPaths.Values], Ct);
            byRawPath.Count.ShouldBe(ids.Count);
            byRawPath.ShouldContain(r => r.StatusCode == Good && r.Value is double, "reads must resolve by RawPath");

            var seen = new ConcurrentQueue<DataChangeEventArgs>();
            driver.OnDataChange += (_, e) => seen.Enqueue(e);

            var handle = await driver.SubscribeAsync([.. rawPaths.Values], TimeSpan.FromMilliseconds(200), Ct);
            try
            {
                seen.ShouldNotBeEmpty("subscribe primes every reference from the /current snapshot");
                seen.Select(e => e.FullReference)
                    .ShouldAllBe(reference => rawPaths.Values.Contains(reference));
            }
            finally
            {
                await driver.UnsubscribeAsync(handle, CancellationToken.None);
            }
        }
        finally
        {
            await driver.ShutdownAsync(CancellationToken.None);
        }
    }

    // ---- helpers ----

    private void SkipIfDown()
    {
        if (agent.SkipReason is not null)
        {
            Assert.Skip(agent.SkipReason);
        }
    }

    private static bool Is(string? value, string expected) =>
        string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase);

    /// <summary>The minimal Test Connect config: the probe reads nothing but <c>agentUri</c>.</summary>
    private static string ProbeConfig(string agentUri) =>
        JsonSerializer.Serialize(new { agentUri });

    /// <summary>
    ///     A loopback port with nothing listening on it: bound to :0 to let the OS pick a free one,
    ///     then released. Racy in principle, effectively certain in practice, and far more reliable
    ///     than guessing an unused constant.
    /// </summary>
    private static int ClosedLoopbackPort()
    {
        var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
        listener.Start();
        var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();

        return port;
    }

    private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
        new()
        {
            AgentUri = agent.AgentUri,
            DeviceName = deviceName,
            RequestTimeoutMs = 15_000,

            // Faster than the 1000 ms default so a subscription sees several distinct chunks inside
            // StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
            SampleIntervalMs = 200,
            SampleCount = 500,
            HeartbeatMs = 5_000,

            // The background connectivity probe is off: it dials the Agent on its own cadence and
            // would interleave request logs with the ones a failing test needs to be read from.
            Probe = new MTConnectProbeOptions { Enabled = false },
        };

    private static string ConfigJson(MTConnectDriverOptions options, IEnumerable<MTConnectTagDefinition>? tags = null) =>
        JsonSerializer.Serialize(new
        {
            agentUri = options.AgentUri,
            deviceName = options.DeviceName,
            requestTimeoutMs = options.RequestTimeoutMs,
            sampleIntervalMs = options.SampleIntervalMs,
            sampleCount = options.SampleCount,
            heartbeatMs = options.HeartbeatMs,
            probe = new { enabled = false },
            tags = (tags ?? []).Select(t => new
            {
                fullName = t.FullName,
                driverDataType = t.DriverDataType.ToString(),
            }),
        });

    private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);

    /// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
    private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
    {
        var options = LiveOptions();
        var driver = new MTConnectDriver(options, "mtconnect-live");
        await driver.InitializeAsync(ConfigJson(options, tags), Ct);

        return new LiveDriver(driver);
    }

    /// <summary>Initializes a driver and captures one full <c>DiscoverAsync</c> pass.</summary>
    private async Task<DiscoveryCapture> DiscoverAsync()
    {
        await using var live = await LiveDriverAsync([]);

        var capture = new DiscoveryCapture();
        await live.Driver.DiscoverAsync(capture, Ct);

        return capture;
    }

    /// <summary>The single captured variable for a DataItem id, asserting it was streamed exactly once.</summary>
    private static CapturedVariable Leaf(DiscoveryCapture capture, string dataItemId)
    {
        var matches = capture.Variables.Where(v => v.Attr.FullName == dataItemId).ToList();
        matches.Count.ShouldBe(1, $"DataItem '{dataItemId}' must be streamed exactly once by discovery");

        return matches[0];
    }

    /// <summary>The Agent's <c>/current</c> snapshot, fetched straight over HTTP.</summary>
    /// <remarks>
    ///     Read outside the driver on purpose: several assertions need to know what the Agent is
    ///     reporting <i>right now</i> in order to build their expectation, and computing that with
    ///     the parser under test would only prove the code agrees with itself.
    /// </remarks>
    private async Task<XDocument> CurrentAsync()
    {
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
        var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct);

        return XDocument.Parse(body);
    }

    /// <summary>
    ///     The DataItem ids the Agent is currently reporting a parseable number for: the live subset
    ///     a read or subscription can make a non-vacuous statement about.
    /// </summary>
    /// <remarks>
    ///     <b>The Agent's own self-model is excluded, and that exclusion is load-bearing.</b> Every
    ///     MTConnect 2.x Agent publishes an <c>&lt;Agent&gt;</c> device whose
    ///     <c>OBSERVATION_UPDATE_RATE</c> / <c>ASSET_UPDATE_RATE</c> SAMPLEs tick continuously
    ///     regardless of whether any adapter is attached. Including them made
    ///     <see cref="Subscribe_delivers_a_changed_value_from_the_live_sample_stream"/> pass with the
    ///     fixture's data source deliberately stopped (verified) — it was proving the Agent's own
    ///     heartbeat, not the device stream. Restricted to <c>&lt;Device&gt;</c>-owned samples, an
    ///     Agent with no live source yields an empty set and the test skips with an actionable
    ///     message instead.
    /// </remarks>
    private async Task<IReadOnlyList<string>> NumericSampleIdsAsync()
    {
        var declaredSamples = agent.DeclaredDataItems
            .Where(d => Is(d.Category, "SAMPLE") && !d.InAgentSelfModel && !d.InComposition)
            .Where(d => !Is(d.Representation, "TIME_SERIES") && !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
            .Select(d => d.Id)
            .ToHashSet(StringComparer.Ordinal);

        return [.. (await CurrentAsync())
            .Descendants()
            .Where(e => !e.HasElements)
            .Select(e => ((string?)e.Attribute("dataItemId"), e.Value.Trim()))
            .Where(pair => pair.Item1 is { Length: > 0 } && declaredSamples.Contains(pair.Item1))
            .Where(pair => double.TryParse(pair.Item2, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
            .Select(pair => pair.Item1!)
            .Distinct(StringComparer.Ordinal)];
    }

    /// <summary>
    ///     Scope guard: <see cref="MTConnectDriver"/> is not disposable (its teardown is
    ///     <c>ShutdownAsync</c>), and a test that failed mid-way would otherwise leave a live
    ///     <c>/sample</c> long poll running against the fixture for the rest of the session.
    /// </summary>
    private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable
    {
        public MTConnectDriver Driver { get; } = driver;

        public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None);
    }
}
