using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.Logging;

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

/// <summary>
///     The production <see cref="IMTConnectAgentClient"/> — a thin <see cref="HttpClient"/> wrapper
///     over an MTConnect Agent's REST surface that hands every response body to a pure parser and
///     returns only the neutral DTOs in <c>MTConnectDtos.cs</c>.
/// </summary>
/// <remarks>
///     <para>
///         <b>The constructor opens no connection.</b> It only composes the request URIs and builds
///         its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
///         so a throwaway instance is safe to construct — the universal discovery browser's
///         <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
///         nothing until it is enumerated.
///     </para>
///     <para>
///         <b>Two <see cref="HttpClient"/>s, and the split is deliberate.</b>
///         <see cref="HttpClient.Timeout"/> is a per-instance, whole-response deadline, so a single
///         client cannot serve both legs: the unary <c>/probe</c> and <c>/current</c> calls must be
///         bounded by <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, while <c>/sample</c> is
///         a long poll that is <i>supposed</i> to outlive that bound indefinitely. Raising the one
///         timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
///         R2-01 says must never happen — so the streaming leg gets its own client with
///         <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below). In
///         production each client owns its own handler and connection pool; in tests both are built
///         over one injected handler, which the caller owns.
///     </para>
///     <para>
///         <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
///         run under a <see cref="CancellationTokenSource"/> linked to the caller's token and
///         cancelled after <c>RequestTimeoutMs</c>. The streaming leg bounds its <i>header</i> phase
///         the same way (a peer that accepts the connection and never answers must not wedge the
///         pump), then hands the body to a <b>heartbeat watchdog</b>: MTConnect Agents emit a
///         keep-alive chunk every <see cref="MTConnectDriverOptions.HeartbeatMs"/> precisely so a
///         client can tell "quiet but alive" from "dead", so every body read is bounded by a
///         two-missed-heartbeat window and a silent Agent surfaces as a
///         <see cref="TimeoutException"/>. This is the exact shape of this repo's S7 frozen-peer
///         production defect (arch-review R2-01) — an async read that ignores the socket deadline —
///         and the watchdog is what closes it here.
///     </para>
///     <para>
///         <b>Every operator-authorable duration/count is rejected at construction if non-positive.</b>
///         A <c>0</c> must never come to mean "wait forever" (arch-review 01/S-6), and the three
///         <c>/sample</c> query knobs additionally reach the wire: an Agent answers
///         <c>heartbeat=0</c> with an <c>MTConnectError</c> under HTTP 200, which would otherwise
///         surface as a parse failure that never names the offending config key.
///     </para>
///     <para>
///         <b>Disposal is safe against an in-flight enumeration.</b> Task 9 re-initializes the
///         driver by disposing and rebuilding this client, which can happen while a pump is still
///         enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
///         an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
///         caller is written to expect, every request runs under a token linked to an internal
///         dispose token, so disposal surfaces as an ordinary
///         <see cref="OperationCanceledException"/>.
///     </para>
///     <para>
///         This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
///         status, an unparseable body, and <b>the end of a <c>/sample</c> stream</b> all throw. See
///         <see cref="MTConnectStreamException"/> for why the last of those is an exception rather
///         than a normal end of enumeration. <c>InitializeAsync</c> (Task 9) is what catches these
///         and moves the driver to Faulted.
///     </para>
/// </remarks>
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
{
    private readonly HttpClient _http;
    private readonly HttpClient _streamHttp;
    private readonly CancellationTokenSource _disposeCts = new();
    private readonly ILogger? _logger;
    private readonly Uri _probeUri;
    private readonly Uri _currentUri;
    private readonly string _sampleUriBase;
    private readonly string _sampleQuerySuffix;
    private readonly TimeSpan _requestTimeout;
    private readonly TimeSpan _streamIdleTimeout;
    private bool _disposed;

    /// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
    /// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
    /// <param name="logger">
    ///     Optional logger. Used only for conditions an operator needs to see but which are not
    ///     failures — today, the degraded <c>/sample</c> framing mode.
    /// </param>
    /// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
    /// <exception cref="ArgumentOutOfRangeException">
    ///     Any of <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>,
    ///     <see cref="MTConnectDriverOptions.HeartbeatMs"/>,
    ///     <see cref="MTConnectDriverOptions.SampleIntervalMs"/> or
    ///     <see cref="MTConnectDriverOptions.SampleCount"/> is not positive.
    /// </exception>
    public MTConnectAgentClient(MTConnectDriverOptions options, ILogger? logger = null)
        : this(options, handler: null, logger)
    {
    }

    /// <summary>
    ///     Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions, ILogger)"/>, but
    ///     sends through the supplied handler so the request/response round trip can be exercised
    ///     with no socket. The caller retains ownership of <paramref name="handler"/>.
    /// </summary>
    internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
    {
        ArgumentNullException.ThrowIfNull(options);

        RequirePositive(
            options.RequestTimeoutMs,
            nameof(MTConnectDriverOptions.RequestTimeoutMs),
            "a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
        RequirePositive(
            options.HeartbeatMs,
            nameof(MTConnectDriverOptions.HeartbeatMs),
            "it is sent to the Agent as 'heartbeat', which an Agent rejects with an MTConnectError under HTTP 200, and it is what bounds the /sample watchdog — a quiet connection would otherwise be indistinguishable from a dead one");
        RequirePositive(
            options.SampleIntervalMs,
            nameof(MTConnectDriverOptions.SampleIntervalMs),
            "it is sent to the Agent as 'interval'; zero would spin the streaming connection into a busy-loop");
        RequirePositive(
            options.SampleCount,
            nameof(MTConnectDriverOptions.SampleCount),
            "it is sent to the Agent as 'count'; a non-positive count asks the Agent for no observations");

        _logger = logger;
        _requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);

        // Two missed heartbeats plus one request timeout of network slack. Deriving the window from
        // HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
        // Agent and an idle one both emit at least one chunk per heartbeat.
        _streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);

        _probeUri = BuildRequestUri(options, "probe");
        _currentUri = BuildRequestUri(options, "current");
        _sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
        _sampleQuerySuffix = string.Create(
            CultureInfo.InvariantCulture,
            $"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");

        _http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
        _http.Timeout = _requestTimeout;

        _streamHttp = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);

        // See the class remarks: a long poll must outlive the unary deadline by design, and the
        // heartbeat watchdog — not this property — is what keeps it bounded.
        _streamHttp.Timeout = Timeout.InfiniteTimeSpan;
    }

    /// <inheritdoc/>
    public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        // ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
        // call, so the parse below reads from memory — no network read can outlive the deadline.
        using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);

        await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);

        return MTConnectProbeParser.Parse(body);
    }

    /// <inheritdoc/>
    public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);

        await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);

        return MTConnectStreamsParser.Parse(body);
    }

    /// <inheritdoc/>
    public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
        long from,
        [EnumeratorCancellation] CancellationToken ct)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        var uri = BuildSampleUri(from);

        // Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
        // surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
        using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
        var live = lifetime.Token;

        using var request = new HttpRequestMessage(HttpMethod.Get, uri);

        // ResponseHeadersRead, not the default ResponseContentRead: buffering a
        // multipart/x-mixed-replace body that is designed never to end would hang here forever.
        using var response = await SendStreamRequestAsync(request, uri, lifetime).ConfigureAwait(false);

        await using var body = await response.Content.ReadAsStreamAsync(live).ConfigureAwait(false);

        var boundary = ResolveMultipartBoundary(response, uri);
        if (boundary is null)
        {
            // Not a multipart stream: the Agent answered with a single document, which means it
            // ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
            // exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
            // more useful than ours — then report the misconfiguration. The parsed snapshot is
            // deliberately NOT yielded: one document followed by a healthy-looking finished stream
            // is how a dead data path disguises itself as a working one (#485).
            var single = await ReadWholeBodyAsync(body, uri, lifetime).ConfigureAwait(false);
            _ = MTConnectStreamsParser.Parse(single);

            throw new MTConnectStreamNotSupportedException(
                $"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
        }

        var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
        var delivered = 0L;
        while (true)
        {
            var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
            if (part is null)
            {
                // The caller cancelling is the ONE contracted way out; anything else is reported.
                live.ThrowIfCancellationRequested();

                throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
            }

            // A frame carrying nothing but whitespace is transport filler, not a document. The
            // Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
            // and that one IS yielded — it advances the sequence, so swallowing it would strand the
            // caller's `from` behind the Agent's buffer.
            if (string.IsNullOrWhiteSpace(part))
            {
                continue;
            }

            delivered++;

            yield return MTConnectStreamsParser.Parse(part);
        }
    }

    /// <inheritdoc/>
    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;

        // Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
        // than a disposed handler.
        _disposeCts.Cancel();
        _http.Dispose();
        _streamHttp.Dispose();
        _disposeCts.Dispose();
    }

    private static void RequirePositive(int value, string optionName, string because)
    {
        if (value <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(MTConnectDriverOptions),
                value,
                $"{optionName} must be positive; {because}.");
        }
    }

    private Uri BuildSampleUri(long from) =>
        new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
            UriKind.Absolute);

    private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
    {
        using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
        deadline.CancelAfter(_requestTimeout);

        HttpResponseMessage response;
        try
        {
            response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
        {
            // Neither the caller nor disposal cancelled, so this is our own deadline (or
            // HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
            // canceled" tells an operator nothing about which Agent stopped answering.
            throw TimedOut(uri);
        }

        response.EnsureSuccessStatusCode();

        return response;
    }

    /// <summary>
    ///     Issues the <c>/sample</c> request and returns as soon as the response headers land. Only
    ///     this phase is bounded by the unary deadline; the body is bounded by the watchdog.
    /// </summary>
    private async Task<HttpResponseMessage> SendStreamRequestAsync(
        HttpRequestMessage request, Uri uri, CancellationTokenSource lifetime)
    {
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
        deadline.CancelAfter(_requestTimeout);

        HttpResponseMessage response;
        try
        {
            response = await _streamHttp
                .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
                .ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
        {
            throw TimedOut(uri);
        }

        response.EnsureSuccessStatusCode();

        return response;
    }

    /// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
    private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
    {
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
        deadline.CancelAfter(_requestTimeout);

        using var reader = new StreamReader(body, Encoding.UTF8);
        try
        {
            return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
        {
            throw TimedOut(uri);
        }
    }

    private TimeoutException TimedOut(Uri uri) =>
        new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");

    /// <summary>
    ///     The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
    ///     Agent answered with a single (non-multipart) document.
    /// </summary>
    /// <exception cref="MTConnectStreamNotSupportedException">
    ///     The response claims to be <c>multipart/*</c> but carries no <c>boundary</c> parameter, so
    ///     there is nothing to frame it by. Failed fast and named, because the alternative — falling
    ///     through to whole-body reading of a body that never ends — surfaces much later as a bare
    ///     watchdog <see cref="TimeoutException"/> that points an operator at the network instead of
    ///     at the malformed header.
    /// </exception>
    private static string? ResolveMultipartBoundary(HttpResponseMessage response, Uri uri)
    {
        var contentType = response.Content.Headers.ContentType;
        if (contentType?.MediaType is null ||
            !contentType.MediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }

        var boundary = contentType.Parameters
            .FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
            .Trim('"');

        if (string.IsNullOrEmpty(boundary))
        {
            throw new MTConnectStreamNotSupportedException(
                $"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
        }

        return boundary;
    }

    private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
    {
        var agentUri = options.AgentUri?.Trim();
        if (string.IsNullOrEmpty(agentUri))
        {
            throw new ArgumentException(
                $"{nameof(MTConnectDriverOptions.AgentUri)} is required (e.g. 'http://agent:5000').", nameof(options));
        }

        if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
            (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
        {
            throw new ArgumentException(
                $"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
        }

        var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
        var deviceName = options.DeviceName?.Trim();
        var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";

        return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
    }

    /// <summary>
    ///     Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
    ///     incrementally and never buffering more than the part currently in flight.
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
    ///         dependency set, and TrakHound's <c>MTConnectHttpClientStream</c> — the one candidate
    ///         the plan left open — takes a URL and dials its own socket, so it can neither be fed an
    ///         existing response stream nor be exercised without a listener. See the Task 0
    ///         correction in the plan.
    ///     </para>
    ///     <para>
    ///         <b>Two framing paths, and the first one matters for latency.</b> When the part carries
    ///         a <c>Content-length</c> header — every Agent in the wild writes one — the body is read
    ///         by length and the chunk is emitted the instant it is complete. Without it there is no
    ///         way to know a part has ended except by seeing the <i>next</i> boundary, so that path
    ///         necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
    ///         can be up to a heartbeat stale on a driver built for low-latency subscribe. It is a
    ///         correctness fallback, not the expected path, and it logs a one-shot warning when it
    ///         engages so the degradation is visible rather than merely slow. (It cannot strand the
    ///         final part indefinitely — a clean end of stream returns the buffered tail — though a
    ///         watchdog timeout on a stalled Agent does discard it.)
    ///     </para>
    ///     <para>
    ///         <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
    ///         parking a task forever (arch-review R2-01), and the buffer is capped so a hostile or
    ///         malfunctioning Agent cannot grow it without limit.
    ///     </para>
    /// </remarks>
    private sealed class MultipartStreamReader(
        Stream stream, string boundary, TimeSpan idleTimeout, Uri uri, ILogger? logger)
    {
        private const int MaxBufferBytes = 32 * 1024 * 1024;

        private static readonly byte[] CrLfCrLf = "\r\n\r\n"u8.ToArray();
        private static readonly byte[] LfLf = "\n\n"u8.ToArray();

        private readonly byte[] _delimiter = Encoding.ASCII.GetBytes("--" + boundary);
        private byte[] _buffer = new byte[16 * 1024];
        private int _length;
        private bool _finished;
        private bool _warnedNoContentLength;

        /// <summary>How the stream ended. Only meaningful once a read has returned <c>null</c>.</summary>
        public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;

        /// <summary>
        ///     Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
        ///     the Agent closes the connection — see <see cref="EndReason"/> for which.
        /// </summary>
        public async Task<string?> ReadNextPartAsync(CancellationToken ct)
        {
            if (_finished)
            {
                return null;
            }

            if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
                !await EnsureAsync(2, ct).ConfigureAwait(false))
            {
                return Finish(MTConnectStreamEndReason.ConnectionClosed);
            }

            // "--boundary--" closes the stream.
            if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
            {
                return Finish(MTConnectStreamEndReason.ClosingBoundary);
            }

            var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
            if (headerEnd < 0)
            {
                return Finish(MTConnectStreamEndReason.ConnectionClosed);
            }

            var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
            Consume(headerEnd);

            byte[]? body;
            if (ParseContentLength(headers) is { } declared)
            {
                body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
            }
            else
            {
                WarnOnceAboutMissingContentLength();
                body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
            }

            return body is null ? null : Encoding.UTF8.GetString(body);
        }

        private void WarnOnceAboutMissingContentLength()
        {
            if (_warnedNoContentLength)
            {
                return;
            }

            _warnedNoContentLength = true;

            logger?.LogWarning(
                "MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
                uri);
        }

        private string? Finish(MTConnectStreamEndReason reason)
        {
            _finished = true;
            EndReason = reason;

            return null;
        }

        private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
        {
            if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
            {
                Finish(MTConnectStreamEndReason.ConnectionClosed);

                return null;
            }

            var body = _buffer[..declared];
            Consume(declared);

            return body;
        }

        private async Task<byte[]?> ReadToNextDelimiterAsync(CancellationToken ct)
        {
            var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
            if (next < 0)
            {
                // EOF with no closing delimiter: whatever is buffered is the last part. Returned
                // rather than dropped, so a clean end of stream cannot strand a chunk.
                var tail = _buffer[.._length];
                _length = 0;
                Finish(MTConnectStreamEndReason.ConnectionClosed);

                return tail;
            }

            var body = _buffer[..TrimTrailingLineBreak(next)];

            // Leave the delimiter itself in place; the next call's skip consumes it.
            Consume(next);

            return body;
        }

        /// <summary>Strips the single line break that terminates a part body before its delimiter.</summary>
        private int TrimTrailingLineBreak(int end)
        {
            if (end >= 2 && _buffer[end - 2] == (byte)'\r' && _buffer[end - 1] == (byte)'\n')
            {
                return end - 2;
            }

            return end >= 1 && _buffer[end - 1] == (byte)'\n' ? end - 1 : end;
        }

        /// <summary>Advances past the next boundary delimiter; <c>false</c> at end of stream.</summary>
        private async Task<bool> SkipThroughDelimiterAsync(CancellationToken ct)
        {
            var index = await FindDelimiterAsync(ct).ConfigureAwait(false);
            if (index < 0)
            {
                return false;
            }

            Consume(index + _delimiter.Length);

            return true;
        }

        private async Task<int> FindDelimiterAsync(CancellationToken ct)
        {
            var searchedTo = 0;
            while (true)
            {
                // Restart the scan far enough back that a delimiter split across two reads is still
                // found — without the overlap, a boundary straddling a packet boundary is invisible.
                var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
                if (index >= 0)
                {
                    return index;
                }

                searchedTo = _length;
                if (!await FillAsync(ct).ConfigureAwait(false))
                {
                    return -1;
                }
            }
        }

        /// <summary>
        ///     Finds the end of the part's header block — the first blank line after the delimiter.
        ///     A part with no headers at all is legal, in which case the blank line sits at offset 0.
        /// </summary>
        private async Task<int> FindHeaderEndAsync(CancellationToken ct)
        {
            var searchedTo = 0;
            while (true)
            {
                var crlf = IndexOf(CrLfCrLf, Math.Max(0, searchedTo - 3));
                var lf = IndexOf(LfLf, Math.Max(0, searchedTo - 1));

                if (crlf >= 0 && (lf < 0 || crlf <= lf))
                {
                    return crlf + CrLfCrLf.Length;
                }

                if (lf >= 0)
                {
                    return lf + LfLf.Length;
                }

                searchedTo = _length;
                if (!await FillAsync(ct).ConfigureAwait(false))
                {
                    return -1;
                }
            }
        }

        private static int? ParseContentLength(string headers)
        {
            foreach (var line in headers.Split('\n'))
            {
                var trimmed = line.Trim();
                if (!trimmed.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }

                var raw = trimmed["content-length:".Length..].Trim();

                return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) &&
                       value >= 0
                    ? value
                    : null;
            }

            return null;
        }

        private async Task<bool> EnsureAsync(int count, CancellationToken ct)
        {
            while (_length < count)
            {
                if (!await FillAsync(ct).ConfigureAwait(false))
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        ///     Reads at least one more byte, bounded by the heartbeat watchdog. Returns <c>false</c>
        ///     at end of stream and throws <see cref="TimeoutException"/> when the Agent goes silent.
        /// </summary>
        private async Task<bool> FillAsync(CancellationToken ct)
        {
            if (_length == _buffer.Length)
            {
                if (_buffer.Length >= MaxBufferBytes)
                {
                    throw new InvalidDataException(
                        $"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
                }

                Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
            }

            using var watchdog = CancellationTokenSource.CreateLinkedTokenSource(ct);
            watchdog.CancelAfter(idleTimeout);

            int read;
            try
            {
                read = await stream.ReadAsync(_buffer.AsMemory(_length), watchdog.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (!ct.IsCancellationRequested)
            {
                throw new TimeoutException(
                    $"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
            }

            if (read <= 0)
            {
                return false;
            }

            _length += read;

            return true;
        }

        private int IndexOf(byte[] pattern, int start)
        {
            if (start >= _length)
            {
                return -1;
            }

            var index = _buffer.AsSpan(start, _length - start).IndexOf(pattern.AsSpan());

            return index < 0 ? -1 : index + start;
        }

        private void Consume(int count)
        {
            Buffer.BlockCopy(_buffer, count, _buffer, 0, _length - count);
            _length -= count;
        }
    }
}
