using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;

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

/// <summary>
///     Task 7 — the <c>/current</c> + <c>/sample</c> legs: <see cref="MTConnectStreamsParser"/>
///     turning an <c>MTConnectStreams</c> document into <see cref="MTConnectStreamsResult"/>, the
///     ring-buffer sequence-gap signal (<see cref="IMTConnectAgentClient.IsSequenceGap"/>), and the
///     <c>multipart/x-mixed-replace</c> chunk framing + heartbeat watchdog on the long-poll leg.
///     Every test runs against canned fixtures or a stubbed <see cref="HttpMessageHandler"/> — no
///     socket is opened anywhere in this file.
/// </summary>
public sealed class MTConnectStreamsParseTests
{
    private const string CurrentFixture = "Fixtures/current.xml";
    private const string SampleFixture = "Fixtures/sample.xml";
    private const string GapFixture = "Fixtures/sample-gap.xml";
    private const string UnavailableFixture = "Fixtures/current-unavailable.xml";

    /// <summary>The fixtures deliberately share one instanceId, so a gap — not a restart — is under test.</summary>
    private const long FixtureInstanceId = 1655000000L;

    private static readonly string[] AllFixtureDataItemIds =
    [
        "dev1_avail",
        "dev1_pos",
        "dev1_vibration_ts",
        "dev1_partcount",
        "dev1_execution",
        "dev1_program",
        "dev1_system_cond"
    ];

    // ------------------------------------------------------------------ header sequence parsing

    [Fact]
    public void Current_parse_reads_header_sequences_and_observations()
    {
        var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));

        result.NextSequence.ShouldBeGreaterThan(0);
        result.Observations.ShouldNotBeEmpty();
    }

    [Theory]
    [InlineData(CurrentFixture, 1L, 108L)]
    [InlineData(SampleFixture, 108L, 113L)]
    [InlineData(GapFixture, 5000L, 5005L)]
    [InlineData(UnavailableFixture, 200L, 207L)]
    public void Parse_reads_first_and_next_sequence_and_the_instance_id_from_every_fixture(
        string fixture, long firstSequence, long nextSequence)
    {
        var result = MTConnectStreamsParser.Parse(File.ReadAllText(fixture));

        result.FirstSequence.ShouldBe(firstSequence);
        result.NextSequence.ShouldBe(nextSequence);
        result.InstanceId.ShouldBe(FixtureInstanceId);
    }

    // ------------------------------------------------------------------------ observation values

    [Fact]
    public void Current_parse_keys_every_observation_by_its_dataitem_id()
    {
        var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));

        result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
    }

    [Fact]
    public void Current_parse_reads_the_good_value_the_agent_reported()
    {
        var observations = ParseCurrent();

        // The observation ELEMENT is <Position>, not <POSITION>; the value is its text content.
        observations["dev1_pos"].Value.ShouldBe("123.4567");
        observations["dev1_execution"].Value.ShouldBe("ACTIVE");
        observations["dev1_program"].Value.ShouldBe("O1234");
        observations["dev1_avail"].Value.ShouldBe("AVAILABLE");
    }

    [Fact]
    public void Current_parse_carries_the_unavailable_text_sentinel_through_verbatim()
    {
        // Task 8 maps UNAVAILABLE => BadNoCommunication; this layer must not pre-interpret it.
        ParseCurrent()["dev1_partcount"].Value.ShouldBe("UNAVAILABLE");
    }

    [Fact]
    public void Current_parse_keeps_a_time_series_vector_as_its_raw_string()
    {
        ParseCurrent()["dev1_vibration_ts"].Value.ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
    }

    // ---------------------------------------------------------------------- CONDITION observations

    [Fact]
    public void A_condition_observations_value_is_its_element_name_not_its_empty_text()
    {
        // <Normal dataItemId="dev1_system_cond" .../> — the element name IS the state, and the
        // element is empty, so a text-content read yields "" and loses the observation entirely.
        ParseCurrent()["dev1_system_cond"].Value.ShouldBe("Normal");
    }

    [Theory]
    [InlineData("Normal", "Normal")]
    [InlineData("Warning", "Warning")]
    [InlineData("Fault", "Fault")]
    public void A_condition_state_element_name_becomes_the_value(string element, string expected)
    {
        var result = MTConnectStreamsParser.Parse(StreamsDocument(
            $"""<Condition><{element} dataItemId="c" timestamp="2026-07-24T12:00:00Z" sequence="1" type="SYSTEM"/></Condition>"""));

        result.Observations.ShouldHaveSingleItem().Value.ShouldBe(expected);
    }

    [Fact]
    public void A_condition_with_text_content_still_takes_its_value_from_the_element_name()
    {
        // Agents put the operator-facing condition message in the element text; the STATE is the name.
        var result = MTConnectStreamsParser.Parse(StreamsDocument(
            """<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" nativeCode="E17">Spindle overtemperature</Fault></Condition>"""));

        result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
    }

    [Fact]
    public void An_unavailable_condition_element_is_the_unavailable_sentinel()
    {
        // <Unavailable/> is genuinely no-comms and must land on the exact same sentinel Task 8
        // matches for a Sample/Event's literal UNAVAILABLE text.
        var observations = ParseFixture(UnavailableFixture);

        observations["dev1_system_cond"].Value.ShouldBe("UNAVAILABLE");
    }

    [Fact]
    public void The_all_unavailable_fixture_is_all_unavailable()
    {
        var result = MTConnectStreamsParser.Parse(File.ReadAllText(UnavailableFixture));

        result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
        result.Observations.ShouldAllBe(o => o.Value == "UNAVAILABLE");
    }

    // ------------------------------------------------------------------------------- timestamps

    [Fact]
    public void Parse_normalizes_every_timestamp_to_utc()
    {
        // A wrong Kind silently shifts the OPC UA SourceTimestamp by the machine's UTC offset.
        var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));

        result.Observations.ShouldAllBe(o => o.TimestampUtc.Kind == DateTimeKind.Utc);
        ParseCurrent()["dev1_pos"].TimestampUtc
            .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
    }

    [Fact]
    public void An_offset_timestamp_is_converted_to_utc_rather_than_taken_at_face_value()
    {
        var result = MTConnectStreamsParser.Parse(StreamsDocument(
            """<Events><Program dataItemId="p" timestamp="2026-07-24T14:00:00+02:00">O1</Program></Events>"""));

        var observation = result.Observations.ShouldHaveSingleItem();
        observation.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
        observation.TimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc));
    }

    // -------------------------------------------------------------- sequence-gap detection (both ways)

    [Fact]
    public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from()
    {
        var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));

        IMTConnectAgentClient.IsSequenceGap(expectedFrom: 1, chunk).ShouldBeTrue();
    }

    [Fact]
    public void No_sequence_gap_is_reported_for_a_contiguous_chunk()
    {
        // The falsifiability leg: a helper hard-wired to true would pass the gap test alone.
        var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));

        IMTConnectAgentClient.IsSequenceGap(expectedFrom: 108, contiguous).ShouldBeFalse();
    }

    [Theory]
    [InlineData(4999L, true)]   // expected older than the buffer holds — data was lost
    [InlineData(5000L, false)]  // exactly the oldest retained sequence — nothing was lost
    [InlineData(5001L, false)]  // the agent simply had nothing older to send
    public void Sequence_gap_is_strictly_firstSequence_greater_than_expected_from(long expectedFrom, bool expected)
    {
        var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture));

        IMTConnectAgentClient.IsSequenceGap(expectedFrom, chunk).ShouldBe(expected);
    }

    // --------------------------------------------------------------- legal but degenerate documents

    [Fact]
    public void A_document_with_a_valid_header_and_no_observations_parses_rather_than_throwing()
    {
        // /sample chunks are deltas; an idle agent legitimately sends a document with nothing in it.
        const string Xml = """
                           <MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
                             <Header instanceId="7" firstSequence="10" lastSequence="9" nextSequence="10"/>
                             <Streams/>
                           </MTConnectStreams>
                           """;

        var result = MTConnectStreamsParser.Parse(Xml);

        result.Observations.ShouldBeEmpty();
        result.InstanceId.ShouldBe(7);
        result.NextSequence.ShouldBe(10);
        result.FirstSequence.ShouldBe(10);
    }

    [Fact]
    public void A_component_stream_may_omit_any_of_samples_events_and_condition()
    {
        // sample.xml carries no <Condition> at all; absence is normal, not an error.
        var result = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture));

        result.Observations.Select(o => o.DataItemId).ShouldBe(
            ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program"], ignoreOrder: true);
    }

    [Fact]
    public void Parse_collects_observations_across_every_device_and_component_stream()
    {
        const string Xml = """
                           <MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:2.0">
                             <Header instanceId="1" firstSequence="1" lastSequence="3" nextSequence="4"/>
                             <Streams>
                               <DeviceStream name="A" uuid="a">
                                 <ComponentStream component="Device" componentId="a">
                                   <Events><Availability dataItemId="a_avail" timestamp="2026-07-24T12:00:00Z">AVAILABLE</Availability></Events>
                                 </ComponentStream>
                               </DeviceStream>
                               <DeviceStream name="B" uuid="b">
                                 <ComponentStream component="Path" componentId="b_path">
                                   <Samples><Position dataItemId="b_pos" timestamp="2026-07-24T12:00:01Z">1.0</Position></Samples>
                                   <Condition><Warning dataItemId="b_cond" timestamp="2026-07-24T12:00:02Z" type="SYSTEM"/></Condition>
                                 </ComponentStream>
                               </DeviceStream>
                             </Streams>
                           </MTConnectStreams>
                           """;

        var result = MTConnectStreamsParser.Parse(Xml);

        result.Observations.Select(o => o.DataItemId).ShouldBe(["a_avail", "b_pos", "b_cond"]);
        result.Observations[2].Value.ShouldBe("Warning");
    }

    [Theory]
    [InlineData("urn:mtconnect.org:MTConnectStreams:1.3")]
    [InlineData("urn:mtconnect.org:MTConnectStreams:2.0")]
    [InlineData("")]
    public void Parse_is_agnostic_to_the_document_namespace_version(string namespaceUri)
    {
        var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\"";
        var xml = $"""
                   <MTConnectStreams{xmlns}>
                     <Header instanceId="1" firstSequence="1" lastSequence="1" nextSequence="2"/>
                     <Streams><DeviceStream><ComponentStream>
                       <Events><Program dataItemId="p" timestamp="2026-07-24T12:00:00Z">O1</Program></Events>
                     </ComponentStream></DeviceStream></Streams>
                   </MTConnectStreams>
                   """;

        MTConnectStreamsParser.Parse(xml).Observations.ShouldHaveSingleItem().DataItemId.ShouldBe("p");
    }

    [Fact]
    public void Parse_from_a_stream_matches_the_string_overload()
    {
        using var stream = File.OpenRead(CurrentFixture);

        var fromStream = MTConnectStreamsParser.Parse(stream);

        var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));
        fromStream.NextSequence.ShouldBe(fromString.NextSequence);
        fromStream.Observations.ShouldBe(fromString.Observations);
    }

    // ------------------------------------------------------------- fails loudly, never silently empty

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("not xml at all")]
    [InlineData("<MTConnectStreams><Streams>")]
    public void Parse_throws_on_malformed_xml(string xml)
    {
        Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
    }

    [Fact]
    public void Parse_throws_on_a_devices_document_served_where_streams_were_expected()
    {
        var ex = Should.Throw<InvalidDataException>(
            () => MTConnectStreamsParser.Parse("<MTConnectDevices><Devices/></MTConnectDevices>"));

        ex.Message.ShouldContain("MTConnectStreams");
        ex.Message.ShouldContain("MTConnectDevices");
    }

    [Fact]
    public void Parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error()
    {
        // An agent answers an out-of-range `from` with an MTConnectError document under HTTP 200.
        const string Xml = """
                           <MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
                             <Header instanceId="1"/>
                             <Errors>
                               <Error errorCode="OUT_OF_RANGE">'from' must be greater than 100</Error>
                             </Errors>
                           </MTConnectError>
                           """;

        var ex = Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(Xml));

        ex.Message.ShouldContain("OUT_OF_RANGE");
        ex.Message.ShouldContain("'from' must be greater than 100");
    }

    [Theory]
    [InlineData("")]                                                                        // no <Header> at all
    [InlineData("""<Header instanceId="1" firstSequence="1"/>""")]                           // no nextSequence
    [InlineData("""<Header instanceId="1" nextSequence="2"/>""")]                            // no firstSequence
    [InlineData("""<Header firstSequence="1" nextSequence="2"/>""")]                         // no instanceId
    [InlineData("""<Header instanceId="1" firstSequence="one" nextSequence="2"/>""")]        // non-numeric
    [InlineData("""<Header instanceId="1" firstSequence="1" nextSequence="lots"/>""")]       // non-numeric
    public void Parse_throws_on_an_unusable_header(string headerXml)
    {
        var xml = $"<MTConnectStreams>{headerXml}<Streams/></MTConnectStreams>";

        Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
    }

    [Theory]
    [InlineData("""<Position timestamp="2026-07-24T12:00:00Z">1</Position>""")]              // no dataItemId
    [InlineData("""<Position dataItemId="p">1</Position>""")]                                // no timestamp
    [InlineData("""<Position dataItemId="p" timestamp="whenever">1</Position>""")]           // unparseable timestamp
    public void Parse_throws_on_a_malformed_observation(string observationXml)
    {
        Should.Throw<InvalidDataException>(
            () => MTConnectStreamsParser.Parse(StreamsDocument($"<Samples>{observationXml}</Samples>")));
    }

    [Fact]
    public void Parse_reads_only_unqualified_attributes()
    {
        // A prefixed x:dataItemId is a vendor extension, not the observation's identity — reading it
        // would invent a data item that the probe never declared.
        var xml = StreamsDocument(
            """<Samples><Position xmlns:x="urn:vendor" x:dataItemId="p" timestamp="2026-07-24T12:00:00Z">1</Position></Samples>""");

        Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
    }

    // ------------------------------------------------------------- MTConnectAgentClient.CurrentAsync

    [Fact]
    public async Task CurrentAsync_requests_the_agent_current_path_and_returns_the_parsed_snapshot()
    {
        var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var result = await client.CurrentAsync(Ct);

        handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/current");
        result.NextSequence.ShouldBe(108);
        result.Observations.Count.ShouldBe(7);
    }

    [Fact]
    public async Task CurrentAsync_scopes_the_request_to_the_configured_device_name()
    {
        var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
        using var client = NewClient(
            handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000/", DeviceName = "VMC 3Axis" });

        await client.CurrentAsync(Ct);

        handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/VMC%203Axis/current");
    }

    [Fact]
    public async Task CurrentAsync_bounds_a_hung_agent_by_the_configured_request_timeout()
    {
        using var client = NewClient(
            StubHandler.Hanging(),
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 });

        var started = Stopwatch.StartNew();
        var ex = await Should.ThrowAsync<TimeoutException>(async () => await client.CurrentAsync(Ct));

        ex.ShouldNotBeAssignableTo<OperationCanceledException>();
        ex.Message.ShouldContain("http://agent:5000/current");
        started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
    }

    [Fact]
    public async Task CurrentAsync_throws_on_an_error_document_served_under_http_200()
    {
        var handler = StubHandler.RespondingWith(
            """
            <MTConnectError><Errors><Error errorCode="NO_DEVICE">nope</Error></Errors></MTConnectError>
            """);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var ex = await Should.ThrowAsync<InvalidDataException>(async () => await client.CurrentAsync(Ct));

        ex.Message.ShouldContain("NO_DEVICE");
    }

    [Fact]
    public async Task CurrentAsync_throws_on_a_non_success_status()
    {
        var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        await Should.ThrowAsync<HttpRequestException>(async () => await client.CurrentAsync(Ct));
    }

    // -------------------------------------------------------------- MTConnectAgentClient.SampleAsync

    [Fact]
    public async Task SampleAsync_composes_the_from_interval_count_and_heartbeat_query()
    {
        var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions
        {
            AgentUri = "http://agent:5000",
            DeviceName = "VMC-3Axis",
            SampleIntervalMs = 250,
            SampleCount = 64,
            HeartbeatMs = 7000
        });

        await DrainAsync(client.SampleAsync(108, Ct));

        handler.LastRequestUri!.AbsoluteUri.ShouldBe(
            "http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000");
    }

    [Fact]
    public async Task SampleAsync_yields_one_result_per_multipart_chunk()
    {
        var handler = StubHandler.RespondingWithMultipart(
            "--------bnd",
            await File.ReadAllTextAsync(SampleFixture, Ct),
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(108, Ct));

        chunks.Count.ShouldBe(2);
        chunks[0].FirstSequence.ShouldBe(108);
        chunks[0].NextSequence.ShouldBe(113);
        chunks[1].FirstSequence.ShouldBe(5000);
        chunks[1].NextSequence.ShouldBe(5005);
    }

    [Fact]
    public async Task SampleAsync_frames_chunks_from_the_boundary_when_the_agent_omits_content_length()
    {
        var handler = StubHandler.RespondingWithMultipart(
            "bnd",
            withContentLength: false,
            await File.ReadAllTextAsync(SampleFixture, Ct),
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(108, Ct));

        chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]);
    }

    [Fact]
    public async Task SampleAsync_exposes_the_gap_signal_to_the_caller_on_the_chunk_that_carries_it()
    {
        // Task 11's pump: advance `from` to NextSequence while contiguous, re-baseline on a gap.
        var handler = StubHandler.RespondingWithMultipart(
            "bnd",
            await File.ReadAllTextAsync(SampleFixture, Ct),
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var from = 108L;
        var gaps = new List<bool>();
        await Should.ThrowAsync<MTConnectStreamEndedException>(async () =>
        {
            await foreach (var chunk in client.SampleAsync(from, Ct))
            {
                // `from` is the RUNNING cursor, not the original argument — comparing every chunk
                // against the opening `from` would report a gap on every chunk once the Agent's
                // buffer rolls past it (I1).
                gaps.Add(IMTConnectAgentClient.IsSequenceGap(from, chunk));
                from = chunk.NextSequence;
            }
        });

        gaps.ShouldBe([false, true]);
        from.ShouldBe(5005);
    }

    [Fact]
    public async Task SampleAsync_ignores_an_empty_keepalive_part()
    {
        var handler = StubHandler.RespondingWithMultipart(
            "bnd",
            await File.ReadAllTextAsync(SampleFixture, Ct),
            "",
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(108, Ct));

        chunks.Count.ShouldBe(2);
    }

    [Fact]
    public async Task SampleAsync_yields_a_heartbeat_document_that_carries_no_observations()
    {
        // The agent's real keep-alive is a well-formed but observation-free MTConnectStreams
        // document — it advances the sequence, so it must reach the caller, not be swallowed.
        const string Heartbeat = """
                                 <MTConnectStreams>
                                   <Header instanceId="1655000000" firstSequence="113" lastSequence="112" nextSequence="113"/>
                                   <Streams/>
                                 </MTConnectStreams>
                                 """;
        var handler = StubHandler.RespondingWithMultipart("bnd", Heartbeat);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(113, Ct));

        chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty();
        IMTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse();
    }

    [Fact(Timeout = 30_000)]
    public async Task SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent()
    {
        // The R2-01 frozen-peer shape: the agent accepts the connection, sends one chunk, then goes
        // silent forever. Without a watchdog the pump wedges with no timeout ever firing.
        var prefix = StubHandler.MultipartBody("bnd", withContentLength: true, closing: false,
            await File.ReadAllTextAsync(SampleFixture, Ct));
        var handler = StubHandler.RespondingWithStallingStream("bnd", prefix);
        using var client = NewClient(handler, new MTConnectDriverOptions
        {
            AgentUri = "http://agent:5000",
            RequestTimeoutMs = 100,
            HeartbeatMs = 100
        });

        var started = Stopwatch.StartNew();
        await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct);

        (await chunks.MoveNextAsync()).ShouldBeTrue();
        chunks.Current.FirstSequence.ShouldBe(108);

        var ex = await Should.ThrowAsync<TimeoutException>(async () => await chunks.MoveNextAsync());
        ex.ShouldNotBeAssignableTo<OperationCanceledException>();
        ex.Message.ShouldContain("sample");
        started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20));
    }

    [Fact(Timeout = 30_000)]
    public async Task SampleAsync_bounds_the_response_header_phase_by_the_request_timeout()
    {
        // A peer that accepts the TCP connection but never answers must not wedge the pump either.
        using var client = NewClient(
            StubHandler.Hanging(),
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 });

        var ex = await Should.ThrowAsync<TimeoutException>(async () => await DrainAsync(client.SampleAsync(1, Ct)));

        ex.ShouldNotBeAssignableTo<OperationCanceledException>();
        ex.Message.ShouldContain("/sample");
    }

    [Fact]
    public async Task SampleAsync_throws_on_an_error_document_served_under_http_200()
    {
        // Not a multipart body at all — the agent answers a bad `from` with an error document.
        var handler = StubHandler.RespondingWith(
            """
            <MTConnectError><Errors><Error errorCode="OUT_OF_RANGE">bad from</Error></Errors></MTConnectError>
            """);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var ex = await Should.ThrowAsync<InvalidDataException>(
            async () => await DrainAsync(client.SampleAsync(1, Ct)));

        ex.Message.ShouldContain("OUT_OF_RANGE");
    }

    [Fact]
    public async Task SampleAsync_throws_on_a_non_success_status()
    {
        var handler = StubHandler.RespondingWith("nope", HttpStatusCode.BadRequest);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        await Should.ThrowAsync<HttpRequestException>(async () => await DrainAsync(client.SampleAsync(1, Ct)));
    }

    [Fact(Timeout = 30_000)]
    public async Task SampleAsync_honours_the_callers_cancellation_token()
    {
        var handler = StubHandler.RespondingWithStallingStream("bnd", []);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(
            async () => await DrainAsync(client.SampleAsync(1, cts.Token)));
    }

    [Fact]
    public async Task SampleAsync_reports_the_closing_boundary_rather_than_ending_quietly()
    {
        // C1. The seam contracts SampleAsync to run until cancelled, so a pump has no reason to
        // handle normal completion: returning here would either drop the subscription silently or
        // hot-loop reconnecting on an enumerator that ends immediately (#485).
        var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = new List<MTConnectStreamsResult>();
        var ex = await Should.ThrowAsync<MTConnectStreamEndedException>(async () =>
        {
            await foreach (var chunk in client.SampleAsync(108, Ct))
            {
                chunks.Add(chunk);
            }
        });

        chunks.ShouldHaveSingleItem();
        ex.Reason.ShouldBe(MTConnectStreamEndReason.ClosingBoundary);
        ex.ChunksDelivered.ShouldBe(1);
        ex.AgentUri.ShouldContain("/sample?from=108");
    }

    [Fact]
    public async Task SampleAsync_reports_a_connection_that_drops_mid_stream()
    {
        // No closing boundary — the body simply stops. Distinguished from a deliberate close so an
        // operator can tell a flaky network from an Agent honouring a bounded request.
        var truncated = StubHandler.MultipartBody(
            "bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct));
        var handler = StubHandler.RespondingWithBody("bnd", truncated);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var ex = await Should.ThrowAsync<MTConnectStreamEndedException>(
            async () => await EnumerateAsync(client.SampleAsync(108, Ct)));

        ex.Reason.ShouldBe(MTConnectStreamEndReason.ConnectionClosed);
        ex.ChunksDelivered.ShouldBe(1);
    }

    [Fact]
    public async Task SampleAsync_reports_a_non_multipart_answer_as_a_configuration_error()
    {
        // The worst C1 shape: the Agent ignored the streaming request entirely. Yielding the one
        // snapshot it did return and finishing would report a dead subscription as a healthy one.
        var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(
            async () => await EnumerateAsync(client.SampleAsync(108, Ct)));

        ex.Message.ShouldContain("multipart/x-mixed-replace");
        ex.Message.ShouldContain("text/xml");
    }

    [Fact]
    public async Task SampleAsync_yields_nothing_at_all_from_a_non_multipart_answer()
    {
        // Explicitly pinned: the parsed document must NOT reach the caller. One snapshot plus a
        // finished stream is precisely how a dead data path disguises itself as a working one.
        var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = new List<MTConnectStreamsResult>();
        await Should.ThrowAsync<MTConnectStreamNotSupportedException>(async () =>
        {
            await foreach (var chunk in client.SampleAsync(108, Ct))
            {
                chunks.Add(chunk);
            }
        });

        chunks.ShouldBeEmpty();
    }

    [Fact]
    public async Task SampleAsync_fails_fast_when_a_multipart_response_carries_no_boundary_parameter()
    {
        // M2. Falling through to whole-body reading would surface only as a watchdog TimeoutException
        // much later, pointing an operator at the network instead of at the malformed header.
        var handler = StubHandler.RespondingWithBoundarylessMultipart();
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(
            async () => await EnumerateAsync(client.SampleAsync(1, Ct)));

        ex.Message.ShouldContain("boundary");
    }

    [Fact]
    public async Task A_stream_end_and_a_stream_misconfiguration_share_one_catchable_base_type()
    {
        // A pump that only wants "the stream is over" should not have to name both.
        var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var ended = await Should.ThrowAsync<MTConnectStreamEndedException>(
            async () => await EnumerateAsync(client.SampleAsync(108, Ct)));

        ended.ShouldBeAssignableTo<MTConnectStreamException>();
        typeof(MTConnectStreamException).IsAssignableFrom(typeof(MTConnectStreamNotSupportedException)).ShouldBeTrue();
    }

    [Fact]
    public void SampleAsync_opens_no_connection_until_it_is_enumerated()
    {
        var handler = StubHandler.Hanging();
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        _ = client.SampleAsync(1, CancellationToken.None);

        handler.RequestCount.ShouldBe(0);
    }

    // ------------------------------------------- I2: framing when the transport splits every read

    /// <summary>
    ///     Serving the whole body from one buffer completes every frame in a single
    ///     <c>ReadAsync</c>, so the split-boundary path, the split-header path and the multi-fill
    ///     loop are never entered — on the one behaviour whose headline risk is framing. These drive
    ///     the same fixtures through a transport that hands back only N bytes at a time; at N = 1
    ///     every delimiter, every header line and every document is split maximally.
    /// </summary>
    [Theory]
    [InlineData(1)]
    [InlineData(3)]
    [InlineData(7)]
    public async Task SampleAsync_frames_correctly_when_the_transport_splits_every_boundary_and_header(int bytesPerRead)
    {
        var handler = StubHandler.RespondingWithChunkedMultipart(
            "--------bnd",
            bytesPerRead,
            withContentLength: true,
            await File.ReadAllTextAsync(SampleFixture, Ct),
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(108, Ct));

        chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]);
        chunks.Select(c => c.NextSequence).ShouldBe([113L, 5005L]);
        chunks[0].Observations.Count.ShouldBe(5);
    }

    [Theory]
    [InlineData(1)]
    [InlineData(3)]
    [InlineData(7)]
    public async Task SampleAsync_boundary_scan_framing_survives_a_split_transport_too(int bytesPerRead)
    {
        var handler = StubHandler.RespondingWithChunkedMultipart(
            "bnd",
            bytesPerRead,
            withContentLength: false,
            await File.ReadAllTextAsync(SampleFixture, Ct),
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(108, Ct));

        chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]);
    }

    [Fact]
    public async Task SampleAsync_grows_its_buffer_for_a_part_larger_than_the_initial_read_buffer()
    {
        // Forces Array.Resize + a Consume across the grown buffer, which a 2 KB fixture never does.
        var large = LargeStreamsDocument(observationCount: 900);
        Encoding.UTF8.GetByteCount(large).ShouldBeGreaterThan(16 * 1024);

        var handler = StubHandler.RespondingWithChunkedMultipart(
            "bnd", bytesPerRead: 1024, withContentLength: true, large, large);
        using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        var chunks = await DrainAsync(client.SampleAsync(1, Ct));

        chunks.Count.ShouldBe(2);
        chunks[0].Observations.Count.ShouldBe(900);
        chunks[1].Observations.Count.ShouldBe(900);
    }

    // ------------------------------------------------------- I3: disposal during a live enumeration

    [Fact(Timeout = 30_000)]
    public async Task Disposing_the_client_mid_enumeration_surfaces_as_cancellation()
    {
        // Task 9 re-initializes by disposing and rebuilding this client, which can happen while a
        // pump is still enumerating. An unclassified ObjectDisposedException/IOException from the
        // socket is something no caller is written to expect.
        var prefix = StubHandler.MultipartBody(
            "bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct));
        var handler = StubHandler.RespondingWithStallingStream("bnd", prefix);
        var client = NewClient(handler, new MTConnectDriverOptions
        {
            AgentUri = "http://agent:5000",
            RequestTimeoutMs = 5000,
            HeartbeatMs = 20_000
        });

        await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct);
        (await chunks.MoveNextAsync()).ShouldBeTrue();

        var pending = chunks.MoveNextAsync().AsTask();
        client.Dispose();

        var ex = await Should.ThrowAsync<OperationCanceledException>(async () => await pending);
        ex.ShouldNotBeOfType<ObjectDisposedException>();
    }

    [Fact]
    public async Task Using_a_disposed_client_throws_object_disposed_rather_than_dialling()
    {
        var handler = StubHandler.Hanging();
        var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
        client.Dispose();

        await Should.ThrowAsync<ObjectDisposedException>(async () => await client.CurrentAsync(Ct));
        await Should.ThrowAsync<ObjectDisposedException>(
            async () => await EnumerateAsync(client.SampleAsync(1, Ct)));
        handler.RequestCount.ShouldBe(0);
    }

    [Fact]
    public void Dispose_is_idempotent()
    {
        var client = NewClient(StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000" });

        client.Dispose();

        Should.NotThrow(client.Dispose);
    }

    // ------------------------------------------- I4: the degraded framing mode is operator-visible

    [Fact]
    public async Task A_part_without_content_length_warns_once_that_framing_is_degraded()
    {
        var logger = new RecordingLogger();
        var handler = StubHandler.RespondingWithMultipart(
            "bnd",
            withContentLength: false,
            await File.ReadAllTextAsync(SampleFixture, Ct),
            await File.ReadAllTextAsync(GapFixture, Ct));
        using var client = new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger);

        await DrainAsync(client.SampleAsync(108, Ct));

        // Once, not once per part — a per-chunk warning on a long poll is its own outage.
        logger.Warnings.Count.ShouldBe(1);
        logger.Warnings[0].ShouldContain("Content-length");
        logger.Warnings[0].ShouldContain("http://agent:5000/sample");
    }

    [Fact]
    public async Task The_normal_content_length_path_warns_about_nothing()
    {
        var logger = new RecordingLogger();
        var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
        using var client = new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger);

        await DrainAsync(client.SampleAsync(108, Ct));

        logger.Warnings.ShouldBeEmpty();
    }

    // ---------------------------------------- I5: operator-authorable options that reach the wire

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Ctor_rejects_a_non_positive_heartbeat(int heartbeatMs)
    {
        // heartbeat=0 goes on the query string, and an Agent answers it with an MTConnectError
        // under HTTP 200 — surfacing as a parse failure that never names the offending key.
        var ex = Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", HeartbeatMs = heartbeatMs }));

        ex.Message.ShouldContain(nameof(MTConnectDriverOptions.HeartbeatMs));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Ctor_rejects_a_non_positive_sample_interval(int sampleIntervalMs)
    {
        var ex = Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleIntervalMs = sampleIntervalMs }));

        ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleIntervalMs));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Ctor_rejects_a_non_positive_sample_count(int sampleCount)
    {
        var ex = Should.Throw<ArgumentOutOfRangeException>(() => new MTConnectAgentClient(
            new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleCount = sampleCount }));

        ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleCount));
    }

    // ------------------------------------------- structured (DATA_SET / TABLE) observation signal

    [Fact]
    public void An_observation_whose_content_is_child_entries_is_flagged_structured()
    {
        // <Entry> children concatenate to "12" through element.Value — keys gone, values run
        // together. The index codes this BadNotSupported rather than publishing noise as Good.
        var result = MTConnectStreamsParser.Parse(StreamsDocument(
            """
            <Events><PathFeedrateOverride dataItemId="ds" timestamp="2026-07-24T12:00:00Z" count="2">
              <Entry key="V1">1</Entry><Entry key="V2">2</Entry>
            </PathFeedrateOverride></Events>
            """));

        var observation = result.Observations.ShouldHaveSingleItem();
        observation.IsStructured.ShouldBeTrue();
        observation.DataItemId.ShouldBe("ds");
    }

    [Fact]
    public void A_plain_text_observation_containing_spaces_is_NOT_flagged_structured()
    {
        // The load-bearing case: this proves the signal is "the element had element children", not
        // a whitespace heuristic. A Message reading "Coolant level low" is perfectly valid data and
        // is textually indistinguishable from concatenated entries.
        var result = MTConnectStreamsParser.Parse(StreamsDocument(
            """<Events><Message dataItemId="msg" timestamp="2026-07-24T12:00:00Z">Coolant level low</Message></Events>"""));

        var observation = result.Observations.ShouldHaveSingleItem();
        observation.IsStructured.ShouldBeFalse();
        observation.Value.ShouldBe("Coolant level low");
    }

    [Fact]
    public void Every_observation_in_the_committed_fixtures_is_unstructured()
    {
        // Includes the space-separated TIME_SERIES vector, which is text and must stay unflagged.
        var current = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture));

        current.Observations.ShouldAllBe(o => !o.IsStructured);
    }

    [Fact]
    public void A_condition_is_never_flagged_structured_even_with_child_content()
    {
        // A condition's value comes from the element NAME, so child content cannot corrupt it.
        // Flagging one would throw away a perfectly well-determined state.
        var result = MTConnectStreamsParser.Parse(StreamsDocument(
            """
            <Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" type="SYSTEM">
              <Detail>vendor extension</Detail>
            </Fault></Condition>
            """));

        var observation = result.Observations.ShouldHaveSingleItem();
        observation.IsStructured.ShouldBeFalse();
        observation.Value.ShouldBe("Fault");
    }

    // ------------------------------------------------------------------------------------ helpers

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

    private static MTConnectAgentClient NewClient(HttpMessageHandler handler, MTConnectDriverOptions options) =>
        new(options, handler);

    private static async Task<List<MTConnectStreamsResult>> DrainAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
    {
        var results = new List<MTConnectStreamsResult>();
        try
        {
            await foreach (var item in source)
            {
                results.Add(item);
            }
        }
        catch (MTConnectStreamEndedException)
        {
            // Expected under the C1 contract: every non-cancellation end of a /sample stream is
            // loud. Tests that care about HOW it ended assert on the exception directly.
        }

        return results;
    }

    /// <summary>Enumerates to completion, discarding chunks and letting every exception escape.</summary>
    private static async Task EnumerateAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
    {
        await foreach (var _ in source)
        {
            // Drained for effect only.
        }
    }

    /// <summary>A streams document big enough to force the frame reader to grow its buffer.</summary>
    private static string LargeStreamsDocument(int observationCount)
    {
        var samples = new StringBuilder();
        for (var i = 0; i < observationCount; i++)
        {
            samples.Append(CultureInfo.InvariantCulture,
                $"""<Position dataItemId="d{i}" timestamp="2026-07-24T12:00:00Z" sequence="{i + 1}">{i}.5</Position>""");
        }

        return $"""
                <MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
                  <Header instanceId="1655000000" firstSequence="1" lastSequence="{observationCount}" nextSequence="{observationCount + 1}"/>
                  <Streams><DeviceStream name="D" uuid="d"><ComponentStream component="Path" componentId="p">
                    <Samples>{samples}</Samples>
                  </ComponentStream></DeviceStream></Streams>
                </MTConnectStreams>
                """;
    }

    private static Dictionary<string, MTConnectObservation> ParseCurrent() => ParseFixture(CurrentFixture);

    private static Dictionary<string, MTConnectObservation> ParseFixture(string path) =>
        MTConnectStreamsParser.Parse(File.ReadAllText(path)).Observations.ToDictionary(o => o.DataItemId);

    /// <summary>Wraps observation-container XML in a minimal but valid streams document.</summary>
    private static string StreamsDocument(string componentStreamBody) =>
        $"""
         <MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
           <Header instanceId="1" firstSequence="1" lastSequence="1" nextSequence="2"/>
           <Streams>
             <DeviceStream name="D" uuid="d">
               <ComponentStream component="Path" componentId="d_path">{componentStreamBody}</ComponentStream>
             </DeviceStream>
           </Streams>
         </MTConnectStreams>
         """;

    /// <summary>A canned <see cref="HttpMessageHandler"/> — no socket is ever opened.</summary>
    private sealed class StubHandler : HttpMessageHandler
    {
        private readonly Func<HttpResponseMessage>? _responder;
        private readonly bool _hang;

        private StubHandler(Func<HttpResponseMessage>? responder, bool hang)
        {
            _responder = responder;
            _hang = hang;
        }

        public Uri? LastRequestUri { get; private set; }

        public int RequestCount { get; private set; }

        public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) =>
            new(() => new HttpResponseMessage(status)
            {
                Content = new StringContent(body, Encoding.UTF8, "text/xml")
            }, hang: false);

        public static StubHandler Hanging() => new(responder: null, hang: true);

        public static StubHandler RespondingWithMultipart(string boundary, params string[] parts) =>
            RespondingWithMultipart(boundary, withContentLength: true, parts);

        public static StubHandler RespondingWithMultipart(string boundary, bool withContentLength, params string[] parts)
        {
            var body = MultipartBody(boundary, withContentLength, closing: true, parts);

            return new StubHandler(() => Multipart(boundary, new ByteArrayContent(body)), hang: false);
        }

        public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) =>
            new(() => Multipart(boundary, new StallingContent(prefix)), hang: false);

        /// <summary>Serves an already-built multipart body verbatim (e.g. one with no closing delimiter).</summary>
        public static StubHandler RespondingWithBody(string boundary, byte[] body) =>
            new(() => Multipart(boundary, new ByteArrayContent(body)), hang: false);

        /// <summary>
        ///     Serves a multipart body through a transport that hands back at most
        ///     <paramref name="bytesPerRead"/> bytes per <c>ReadAsync</c>, so boundaries, part
        ///     headers and documents are all split across reads.
        /// </summary>
        public static StubHandler RespondingWithChunkedMultipart(
            string boundary, int bytesPerRead, bool withContentLength, params string[] parts)
        {
            var body = MultipartBody(boundary, withContentLength, closing: true, parts);

            return new StubHandler(() => Multipart(boundary, new ChunkedContent(body, bytesPerRead)), hang: false);
        }

        /// <summary>A response that claims multipart but omits the <c>boundary</c> parameter.</summary>
        public static StubHandler RespondingWithBoundarylessMultipart() =>
            new(
                () =>
                {
                    var content = new ByteArrayContent([]);
                    content.Headers.ContentType = new MediaTypeHeaderValue("multipart/x-mixed-replace");

                    return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
                },
                hang: false);

        /// <summary>Builds a <c>multipart/x-mixed-replace</c> body exactly as an Agent writes one.</summary>
        public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts)
        {
            var body = new List<byte>();
            foreach (var part in parts)
            {
                var payload = Encoding.UTF8.GetBytes(part);
                var header = new StringBuilder()
                    .Append("--").Append(boundary).Append("\r\n")
                    .Append("Content-type: text/xml\r\n");
                if (withContentLength)
                {
                    header.Append("Content-length: ").Append(payload.Length).Append("\r\n");
                }

                header.Append("\r\n");
                body.AddRange(Encoding.ASCII.GetBytes(header.ToString()));
                body.AddRange(payload);
                body.AddRange("\r\n"u8.ToArray());
            }

            if (closing)
            {
                body.AddRange(Encoding.ASCII.GetBytes($"--{boundary}--\r\n"));
            }

            return [.. body];
        }

        private static HttpResponseMessage Multipart(string boundary, HttpContent content)
        {
            content.Headers.ContentType =
                MediaTypeHeaderValue.Parse($"multipart/x-mixed-replace; boundary={boundary}");

            return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
        }

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            LastRequestUri = request.RequestUri;
            RequestCount++;

            if (_hang)
            {
                await Task.Delay(Timeout.Infinite, cancellationToken);
            }

            return _responder!();
        }
    }

    /// <summary>Hands back at most <c>bytesPerRead</c> bytes per read — the split-transport double.</summary>
    private sealed class ChunkedContent(byte[] body, int bytesPerRead) : HttpContent
    {
        protected override Task<Stream> CreateContentReadStreamAsync() =>
            Task.FromResult<Stream>(new ChunkedStream(body, bytesPerRead));

        protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
            stream.WriteAsync(body, 0, body.Length);

        protected override bool TryComputeLength(out long length)
        {
            length = body.Length;

            return true;
        }
    }

    private sealed class ChunkedStream(byte[] body, int bytesPerRead) : Stream
    {
        private int _position;

        public override bool CanRead => true;

        public override bool CanSeek => false;

        public override bool CanWrite => false;

        public override long Length => body.Length;

        public override long Position
        {
            get => _position;
            set => throw new NotSupportedException();
        }

        public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (_position >= body.Length)
            {
                return ValueTask.FromResult(0);
            }

            var count = Math.Min(Math.Min(bytesPerRead, buffer.Length), body.Length - _position);
            body.AsMemory(_position, count).CopyTo(buffer);
            _position += count;

            return ValueTask.FromResult(count);
        }

        public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
            ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();

        public override void Flush()
        {
        }

        public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();

        public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();

        public override void SetLength(long value) => throw new NotSupportedException();

        public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
    }

    /// <summary>Captures warning-level log lines so the degraded-framing notice can be asserted.</summary>
    private sealed class RecordingLogger : ILogger
    {
        public List<string> Warnings { get; } = [];

        public IDisposable? BeginScope<TState>(TState state)
            where TState : notnull => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel,
            EventId eventId,
            TState state,
            Exception? exception,
            Func<TState, Exception?, string> formatter)
        {
            if (logLevel == LogLevel.Warning)
            {
                Warnings.Add(formatter(state, exception));
            }
        }
    }

    /// <summary>Serves <paramref name="prefix"/>, then never completes another read.</summary>
    private sealed class StallingContent(byte[] prefix) : HttpContent
    {
        protected override Task<Stream> CreateContentReadStreamAsync() =>
            Task.FromResult<Stream>(new StallingStream(prefix));

        protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
            Task.CompletedTask;

        protected override bool TryComputeLength(out long length)
        {
            length = -1;

            return false;
        }
    }

    private sealed class StallingStream(byte[] prefix) : Stream
    {
        private int _position;

        public override bool CanRead => true;

        public override bool CanSeek => false;

        public override bool CanWrite => false;

        public override long Length => throw new NotSupportedException();

        public override long Position
        {
            get => throw new NotSupportedException();
            set => throw new NotSupportedException();
        }

        public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
        {
            if (_position < prefix.Length)
            {
                var count = Math.Min(buffer.Length, prefix.Length - _position);
                prefix.AsMemory(_position, count).CopyTo(buffer);
                _position += count;

                return count;
            }

            await Task.Delay(Timeout.Infinite, cancellationToken);

            return 0;
        }

        public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
            ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();

        public override void Flush()
        {
        }

        public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();

        public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();

        public override void SetLength(long value) => throw new NotSupportedException();

        public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
    }
}
