using System.Buffers;
using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.Transfer;

/// <summary>Which way the bytes are going.</summary>
public enum TransferDirection
{
    /// <summary>From the host to this machine.</summary>
    Download = 0,

    /// <summary>From this machine to the host.</summary>
    Upload = 1,
}

/// <summary>Where one transfer has got to.</summary>
public enum TransferState
{
    /// <summary>Waiting for the one in front of it.</summary>
    Queued = 0,

    /// <summary>Moving bytes.</summary>
    Running = 1,

    /// <summary>Finished, and the file is at its final name.</summary>
    Completed = 2,

    /// <summary>Stopped by a failure, which <see cref="TransferSnapshot.Failure"/> describes.</summary>
    Failed = 3,

    /// <summary>Stopped because the user asked.</summary>
    Cancelled = 4,
}

/// <summary>
/// One transfer, as the queue last saw it.
/// </summary>
/// <remarks>
/// A snapshot rather than a live object, because the queue mutates its entries from whichever thread the
/// pump is on and the interface reads them from the UI thread. Handing out an immutable record per change is
/// what lets the view model be a plain list of values with no locking of its own.
/// </remarks>
/// <param name="Id">Identifies the transfer for <see cref="FileTransferQueue.Cancel"/> and the rest.</param>
/// <param name="Direction">Which way it is going.</param>
/// <param name="Name">The file's own name, which is what a queue row is headed with.</param>
/// <param name="LocalPath">The local end, whichever end that is.</param>
/// <param name="RemotePath">The remote end.</param>
/// <param name="Length">
/// How many bytes there are in total, as the side that has the file reported when it was enqueued.
/// </param>
/// <param name="Transferred">How many have moved, resumed bytes included.</param>
/// <param name="State">Where it has got to.</param>
/// <param name="BytesPerSecond">Recent throughput, or zero when nothing is moving.</param>
/// <param name="Failure">Why it stopped, when it stopped badly.</param>
public sealed record TransferSnapshot(
    Guid Id,
    TransferDirection Direction,
    string Name,
    string LocalPath,
    string RemotePath,
    long Length,
    long Transferred,
    TransferState State,
    double BytesPerSecond,
    string? Failure)
{
    /// <summary>How far along, between 0 and 1.</summary>
    /// <remarks>
    /// Zero for an empty file rather than one. A zero-byte transfer is finished the moment it starts and its
    /// row says <see cref="TransferState.Completed"/>; a progress bar that filled instead would be the one
    /// case where the bar told a different story from the state beside it.
    /// </remarks>
    public double Fraction => Length <= 0 ? 0 : Math.Clamp((double)Transferred / Length, 0, 1);

    /// <summary>Whether this transfer will not move again on its own.</summary>
    public bool IsFinished =>
        State is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;

    /// <summary>
    /// Whether there is a partial file to carry on from.
    /// </summary>
    /// <remarks>
    /// Only for a transfer this queue itself left partial. See <see cref="FileTransferQueue.Retry"/> for why
    /// a partial file found lying about is not resumed.
    /// </remarks>
    public bool CanResume =>
        State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
}

/// <summary>A transfer whose state or progress has moved.</summary>
/// <param name="transfer">The transfer, as it now is.</param>
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
{
    /// <summary>The transfer.</summary>
    public TransferSnapshot Transfer { get; } = transfer;
}

/// <summary>
/// The transfer queue: one file at a time, resumable, over one SFTP session.
/// </summary>
/// <remarks>
/// <para>
/// <b>One at a time.</b> Everything here shares one channel's window, so a second concurrent transfer does
/// not make the pair finish sooner — it makes both finish later, and it makes the progress of each
/// unreadable. A serial queue also means the throughput figure on a row is the throughput of the link, which
/// is the only reading of that number anybody acts on.
/// </para>
/// <para>
/// <b>Nothing is written at its final name until it is complete.</b> Every transfer goes to a part file
/// beside its destination and is renamed into place at the end, so an interrupted transfer can never be
/// mistaken for a finished one — which matters most for the thing people actually do with this screen, which
/// is copy a build artefact onto a server and then run it. A destination that already exists is refused
/// outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody
/// else's process is serving is the worse of the two failures.
/// </para>
/// <para>
/// <b>Resume is within a run of the application.</b> A part file left by an interrupted transfer is carried
/// on from by <see cref="Retry"/>, which knows the source it came from. A part file found at startup is not:
/// nothing here records what wrote it, and resuming a file from the middle on the strength of its name
/// matching is how a corrupted artefact gets delivered without anything reporting a failure. Making that
/// survive a restart needs somewhere to write the bookkeeping, which this client does not yet have — see
/// <c>docs/design-import-gaps.md</c> on the missing preferences store.
/// </para>
/// </remarks>
public sealed class FileTransferQueue : IAsyncDisposable
{
    /// <summary>What an in-flight transfer's destination is called until it is finished.</summary>
    /// <remarks>
    /// Long and specific rather than the conventional <c>.part</c>, because this file appears in a directory
    /// somebody else may be looking at — on a shared server, in a deployment directory — and a name that
    /// says which program left it there is the difference between a question and an incident.
    /// </remarks>
    internal const string PartSuffix = ".dodossh-part";

    /// <remarks>
    /// The same 64 KiB the SFTP session reads in, so a copy is one read and one write per SFTP request with
    /// no re-chunking in between.
    /// </remarks>
    private const int BufferSize = 64 * 1024;

    /// <remarks>
    /// Throughput over a window rather than since the start, because the number people read it for is "is it
    /// still going, and how fast now" — an average since the start of a resumed multi-gigabyte transfer
    /// answers a question nobody asked. Half a second is long enough that one slow request does not make it
    /// jump and short enough that a stall shows up before it is worth investigating.
    /// </remarks>
    private static readonly TimeSpan ThroughputWindow = TimeSpan.FromMilliseconds(500);

    /// <remarks>
    /// A progress event per buffer would be some thousands a second on a fast link, each one marshalled to
    /// the UI thread to move a bar by a pixel. Ten a second is smooth to a person and free to the window.
    /// </remarks>
    private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);

    private readonly Func<CancellationToken, Task<IRemoteFileStore>> sessions;
    private readonly TimeProvider clock;
    private readonly List<Entry> transfers = [];
    private readonly Lock gate = new();
    private readonly CancellationTokenSource lifetime = new();

    private Task pump = Task.CompletedTask;
    private bool disposed;

    /// <param name="sessions">
    /// Where the queue gets a session from, asked once per drain. A delegate rather than a session, because
    /// the screen owns the connection and may have re-established it since the last transfer ran — and a
    /// queue holding a stale session would fail every row with a socket error instead of reconnecting.
    /// </param>
    /// <param name="clock">Time source, so throughput is measurable without waiting for real seconds.</param>
    public FileTransferQueue(Func<CancellationToken, Task<IRemoteFileStore>> sessions, TimeProvider clock)
    {
        this.sessions = sessions;
        this.clock = clock;
    }

    /// <summary>
    /// Raised whenever a transfer's state or progress changes.
    /// </summary>
    /// <remarks>
    /// <b>Raised on the pump's thread</b>, which is a thread-pool thread. A handler that touches an
    /// observable collection has to marshal; this project has no toolkit to do it with, which is exactly why
    /// it does not try. The same arrangement as <c>TerminalWorkspace.SessionEnded</c>.
    /// </remarks>
    public event EventHandler<TransferChangedEventArgs>? Changed;

    /// <summary>Every transfer this queue knows about, in the order they were added.</summary>
    public IReadOnlyList<TransferSnapshot> Snapshot()
    {
        lock (gate)
        {
            return [.. transfers.Select(Describe)];
        }
    }

    /// <summary>Whether anything is queued or running.</summary>
    public bool IsBusy
    {
        get
        {
            lock (gate)
            {
                return transfers.Exists(entry =>
                    entry.State is TransferState.Queued or TransferState.Running);
            }
        }
    }

    /// <summary>
    /// Adds a transfer and starts the queue if it is not already running.
    /// </summary>
    /// <param name="direction">Which way.</param>
    /// <param name="localPath">The local end. For a download this is the file to create.</param>
    /// <param name="remotePath">The remote end. For an upload this is the file to create.</param>
    /// <param name="length">
    /// How many bytes there are, from the listing on the side that already has the file. Taken as given
    /// rather than measured here, because the pane the user dragged from has just read it and asking again
    /// would be a round trip to learn something already on screen.
    /// </param>
    /// <returns>The transfer's id.</returns>
    public Guid Enqueue(
        TransferDirection direction,
        string localPath,
        string remotePath,
        long length)
    {
        ArgumentException.ThrowIfNullOrEmpty(localPath);
        ArgumentException.ThrowIfNullOrEmpty(remotePath);

        var entry = new Entry
        {
            Id = Guid.CreateVersion7(),
            Direction = direction,
            LocalPath = localPath,
            RemotePath = remotePath,
            Name = direction is TransferDirection.Download
                ? SftpPath.Name(remotePath)
                : Path.GetFileName(localPath),
            Length = length,
            State = TransferState.Queued,
        };

        lock (gate)
        {
            ObjectDisposedException.ThrowIf(disposed, this);

            transfers.Add(entry);
            EnsurePumping();
        }

        Announce(entry);

        return entry.Id;
    }

    /// <summary>
    /// Stops a transfer, or takes a queued one out of the queue.
    /// </summary>
    /// <remarks>
    /// The part file is left where it is, which is what makes <see cref="Retry"/> a resume rather than a
    /// restart. <see cref="Discard"/> is how a row and its part file go.
    /// </remarks>
    public void Cancel(Guid id)
    {
        Entry? cancelled = null;
        CancellationTokenSource? running = null;

        lock (gate)
        {
            if (Find(id) is not { } entry)
            {
                return;
            }

            switch (entry.State)
            {
                case TransferState.Running:
                    // Marked by the run itself when the cancellation lands, so a transfer that was already
                    // finishing is not relabelled after the fact.
                    running = entry.Cancellation;
                    break;

                case TransferState.Queued:
                    entry.State = TransferState.Cancelled;
                    cancelled = entry;
                    break;

                default:
                    break;
            }
        }

        running?.Cancel();

        if (cancelled is not null)
        {
            Announce(cancelled);
        }
    }

    /// <summary>
    /// Puts a stopped transfer back in the queue, carrying on from where it stopped.
    /// </summary>
    /// <remarks>
    /// Resumes rather than restarts, and only because this object watched the part file being written: it
    /// knows which source those bytes came from. That is the whole of the bookkeeping the design's "resume
    /// supported" needs, and the reason it does not survive a restart — see the remark on this type.
    /// </remarks>
    public void Retry(Guid id)
    {
        Entry? retried = null;

        lock (gate)
        {
            ObjectDisposedException.ThrowIf(disposed, this);

            if (Find(id) is not { State: TransferState.Failed or TransferState.Cancelled } entry)
            {
                return;
            }

            entry.State = TransferState.Queued;
            entry.Failure = null;
            entry.Resume = entry.Transferred > 0;
            retried = entry;

            EnsurePumping();
        }

        Announce(retried);
    }

    /// <summary>
    /// Removes one finished transfer, and whatever it left on disk.
    /// </summary>
    /// <remarks>
    /// Named for the destruction rather than for the tidying. A cancelled transfer's part file holds real
    /// bytes that took real time to move, and the row is the only thing that knows the part file exists — so
    /// removing the row without removing the file would leave litter nobody could attribute, and removing it
    /// silently under a word like "clear" would throw away a resumable transfer without saying so.
    /// </remarks>
    /// <returns>Whether there was such a transfer to discard.</returns>
    public async Task<bool> DiscardAsync(Guid id, CancellationToken cancellationToken)
    {
        Entry? discarded;

        lock (gate)
        {
            if (Find(id) is not { } entry || !IsFinished(entry.State))
            {
                return false;
            }

            transfers.Remove(entry);
            discarded = entry;
        }

        await RemovePartFileAsync(discarded, cancellationToken).ConfigureAwait(false);

        return true;
    }

    /// <summary>Removes every completed transfer, which have nothing left on disk to clean up.</summary>
    /// <returns>How many rows went.</returns>
    public int ClearCompleted()
    {
        lock (gate)
        {
            return transfers.RemoveAll(entry => entry.State is TransferState.Completed);
        }
    }

    /// <inheritdoc />
    /// <remarks>
    /// Cancels whatever is running and waits for it, rather than abandoning the pump. A transfer in flight
    /// holds an open remote file and an open local one, and letting the process move on while they are still
    /// being written is how a part file ends up longer than the bytes that reached it.
    /// </remarks>
    public async ValueTask DisposeAsync()
    {
        Task running;

        lock (gate)
        {
            if (disposed)
            {
                return;
            }

            disposed = true;
            running = pump;
        }

        await lifetime.CancelAsync().ConfigureAwait(false);

        try
        {
            await running.ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // The pump reports failures onto the rows it was running; a fault escaping here would be one
            // nothing is left to show.
        }

        lifetime.Dispose();
    }

    /// <remarks>
    /// Started under the gate and restarted whenever it has finished, which is what makes "one at a time"
    /// true without a dedicated thread waiting on an empty queue for the life of the application.
    /// </remarks>
    private void EnsurePumping()
    {
        if (pump.IsCompleted && !disposed)
        {
            pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
        }
    }

    private async Task PumpAsync(CancellationToken cancellationToken)
    {
        while (TakeNext() is { } entry)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                Stop(entry, TransferState.Cancelled, failure: null);
                continue;
            }

            IRemoteFileStore session;

            try
            {
                session = await sessions(cancellationToken).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                Stop(entry, TransferState.Cancelled, failure: null);
                continue;
            }
            catch (Exception exception) when (exception is not OutOfMemoryException)
            {
                // The session is what every remaining row needs, so a failure to get one is reported on the
                // row that asked and the next iteration asks again. Failing the whole queue would hide which
                // transfer was affected behind a single message.
                Stop(entry, TransferState.Failed, exception.Message);
                continue;
            }

            await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
        }
    }

    /// <summary>Takes the next queued transfer and marks it running.</summary>
    private Entry? TakeNext()
    {
        Entry? next;

        lock (gate)
        {
            next = transfers.Find(entry => entry.State is TransferState.Queued);

            if (next is null)
            {
                return null;
            }

            next.State = TransferState.Running;
            next.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
            next.BytesPerSecond = 0;

            // Reset unless this is a resume, so a retry from the start does not open with a bar most of the
            // way along that then jumps back.
            if (!next.Resume)
            {
                next.Transferred = 0;
            }
        }

        Announce(next);

        return next;
    }

    private async Task RunAsync(Entry entry, IRemoteFileStore session, CancellationToken cancellationToken)
    {
        var token = entry.Cancellation?.Token ?? cancellationToken;

        try
        {
            if (entry.Direction is TransferDirection.Download)
            {
                await DownloadAsync(entry, session, token).ConfigureAwait(false);
            }
            else
            {
                await UploadAsync(entry, session, token).ConfigureAwait(false);
            }

            Stop(entry, TransferState.Completed, failure: null);
        }
        catch (OperationCanceledException)
        {
            Stop(entry, TransferState.Cancelled, failure: null);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            Stop(entry, TransferState.Failed, exception.Message);
        }
        finally
        {
            CancellationTokenSource? source;

            lock (gate)
            {
                source = entry.Cancellation;
                entry.Cancellation = null;
            }

            source?.Dispose();
        }
    }

    private async Task DownloadAsync(Entry entry, IRemoteFileStore session, CancellationToken cancellationToken)
    {
        var destination = entry.LocalPath;

        if (File.Exists(destination))
        {
            throw new IOException(
                $"{Path.GetFileName(destination)} is already in that folder. Rename or remove it first — "
                + "nothing here overwrites a file you already have.");
        }

        var part = destination + PartSuffix;
        var offset = ResumableLength(entry, new FileInfo(part));

        if (offset == 0 && File.Exists(part))
        {
            // A part file this transfer is not resuming from. It belongs to an earlier attempt at the same
            // destination, and starting a fresh transfer by appending to it would produce a file that is
            // longer than the source and wrong in the middle.
            File.Delete(part);
        }

        await ReadIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);

        // Only now, with both handles closed — which is what makes the copy its own method rather than a
        // block here. On Windows a move of a file still open for writing fails, and it is the one step whose
        // failure would leave a complete transfer looking like an incomplete one.
        File.Move(part, destination);
    }

    private async Task ReadIntoPartAsync(
        Entry entry,
        IRemoteFileStore session,
        string part,
        long offset,
        CancellationToken cancellationToken)
    {
        var remote = await session
            .OpenReadAsync(entry.RemotePath, offset, cancellationToken)
            .ConfigureAwait(false);

        await using var remoteScope = remote.ConfigureAwait(false);

        var local = new FileStream(
            part,
            offset == 0 ? FileMode.Create : FileMode.Open,
            FileAccess.Write,
            FileShare.None,
            BufferSize,
            useAsync: true);

        await using var localScope = local.ConfigureAwait(false);

        local.Seek(offset, SeekOrigin.Begin);

        await CopyAsync(remote, local, entry, offset, cancellationToken).ConfigureAwait(false);
    }

    private async Task UploadAsync(Entry entry, IRemoteFileStore session, CancellationToken cancellationToken)
    {
        var destination = entry.RemotePath;

        if (await session.StatAsync(destination, cancellationToken).ConfigureAwait(false) is not null)
        {
            throw new IOException(
                $"{SftpPath.Name(destination)} is already in that directory on the host. Rename or remove it "
                + "first — nothing here overwrites a file that is already there.");
        }

        var part = destination + PartSuffix;
        var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
        var offset = ResumableLength(entry, existing);

        if (offset == 0 && existing is not null)
        {
            await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
        }

        await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);

        await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
    }

    private async Task WriteIntoPartAsync(
        Entry entry,
        IRemoteFileStore session,
        string part,
        long offset,
        CancellationToken cancellationToken)
    {
        var local = new FileStream(
            entry.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);

        await using var localScope = local.ConfigureAwait(false);

        var remote = await session.OpenWriteAsync(part, offset, cancellationToken).ConfigureAwait(false);

        await using var remoteScope = remote.ConfigureAwait(false);

        local.Seek(offset, SeekOrigin.Begin);

        await CopyAsync(local, remote, entry, offset, cancellationToken).ConfigureAwait(false);
    }

    /// <summary>
    /// How far a resume may start, given what is actually on the destination.
    /// </summary>
    /// <remarks>
    /// The part file's own length, not the transfer's recorded progress, and never longer than the source.
    /// The two can disagree — a cancellation lands between a write completing and the counter moving, and a
    /// buffered write may not have reached the disk at all — and only one of them is a fact about the bytes
    /// that are there. Trusting the counter would resume past bytes that were never written, which is the
    /// one way a resumed transfer can produce a corrupt file that nothing reports.
    /// </remarks>
    private static long ResumableLength(Entry entry, FileInfo part) =>
        part.Exists ? ResumableLength(entry, part.Length) : 0;

    private static long ResumableLength(Entry entry, SftpEntry? part) =>
        part is null ? 0 : ResumableLength(entry, part.Length);

    private static long ResumableLength(Entry entry, long partLength)
    {
        if (!entry.Resume || partLength <= 0 || partLength >= entry.Length)
        {
            // A part file at or beyond the source's length is not a resume point; it is evidence that the
            // source changed under a previous attempt. Starting again is the only answer that ends with the
            // right bytes.
            return 0;
        }

        return partLength;
    }

    private async Task CopyAsync(
        Stream source,
        Stream destination,
        Entry entry,
        long startOffset,
        CancellationToken cancellationToken)
    {
        var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
        var meter = new ProgressMeter(clock, startOffset);

        try
        {
            var transferred = startOffset;

            while (true)
            {
                var read = await source
                    .ReadAsync(buffer.AsMemory(0, BufferSize), cancellationToken)
                    .ConfigureAwait(false);

                if (read == 0)
                {
                    break;
                }

                await destination
                    .WriteAsync(buffer.AsMemory(0, read), cancellationToken)
                    .ConfigureAwait(false);

                transferred += read;

                if (Record(entry, meter, transferred))
                {
                    Announce(entry);
                }
            }

            // Flushed before the caller closes the handles and renames, so a write still sitting in a buffer
            // is not counted as delivered by a rename that beat it to the disk.
            await destination.FlushAsync(cancellationToken).ConfigureAwait(false);

            lock (gate)
            {
                entry.Transferred = transferred;
            }
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    /// <summary>Writes progress onto the entry, and says whether it is worth telling anyone.</summary>
    private bool Record(Entry entry, ProgressMeter meter, long transferred)
    {
        var reading = meter.Read(transferred);

        lock (gate)
        {
            entry.Transferred = transferred;

            if (reading.BytesPerSecond is { } rate)
            {
                entry.BytesPerSecond = rate;
            }
        }

        return reading.WorthAnnouncing;
    }

    private void Stop(Entry entry, TransferState state, string? failure)
    {
        lock (gate)
        {
            entry.State = state;
            entry.Failure = failure;
            entry.BytesPerSecond = 0;
            entry.Resume = false;
        }

        Announce(entry);
    }

    private async Task RemovePartFileAsync(Entry entry, CancellationToken cancellationToken)
    {
        try
        {
            if (entry.Direction is TransferDirection.Download)
            {
                File.Delete(entry.LocalPath + PartSuffix);
                return;
            }

            var session = await sessions(cancellationToken).ConfigureAwait(false);
            var part = entry.RemotePath + PartSuffix;

            if (await session.StatAsync(part, cancellationToken).ConfigureAwait(false) is not null)
            {
                await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
            }
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Best effort, and deliberately silent. The row the user asked to remove is already gone, and a
            // remote part file that outlives it is litter rather than a failure — reporting it would mean
            // putting an error on a screen for an operation that did what was asked.
        }
    }

    private Entry? Find(Guid id) => transfers.Find(entry => entry.Id == id);

    private static bool IsFinished(TransferState state) =>
        state is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;

    private void Announce(Entry entry)
    {
        TransferSnapshot snapshot;

        lock (gate)
        {
            snapshot = Describe(entry);
        }

        Changed?.Invoke(this, new TransferChangedEventArgs(snapshot));
    }

    /// <remarks>Callers hold <see cref="gate"/>: every field read here is written from the pump.</remarks>
    private static TransferSnapshot Describe(Entry entry) => new(
        entry.Id,
        entry.Direction,
        entry.Name,
        entry.LocalPath,
        entry.RemotePath,
        entry.Length,
        entry.Transferred,
        entry.State,
        entry.BytesPerSecond,
        entry.Failure);

    /// <summary>
    /// Keeps the two clocks a copy loop needs: when throughput was last sampled, and when the interface was
    /// last told anything.
    /// </summary>
    /// <remarks>
    /// Its own type because both are stateful across iterations, and the alternative — four locals threaded
    /// through the loop by reference — is how the sampling and the announcing end up sharing a timestamp and
    /// quietly becoming one interval. They are deliberately different: half a second is the right window to
    /// measure a rate over, and a tenth of a second is the right rate to repaint at.
    /// </remarks>
    private sealed class ProgressMeter(TimeProvider clock, long startOffset)
    {
        private long sampleAt = clock.GetTimestamp();
        private long sampleBytes = startOffset;
        private long announcedAt = clock.GetTimestamp();

        /// <param name="transferred">Total bytes moved, resumed bytes included.</param>
        /// <returns>
        /// A new throughput figure when the window has elapsed and null when it has not, so a rate is never
        /// recomputed from a sample too short to mean anything; and whether this is a moment to repaint.
        /// </returns>
        public (double? BytesPerSecond, bool WorthAnnouncing) Read(long transferred)
        {
            var now = clock.GetTimestamp();
            var sinceSample = clock.GetElapsedTime(sampleAt, now);

            double? rate = null;

            if (sinceSample >= ThroughputWindow)
            {
                rate = (transferred - sampleBytes) / sinceSample.TotalSeconds;
                sampleAt = now;
                sampleBytes = transferred;
            }

            if (clock.GetElapsedTime(announcedAt, now) < ProgressInterval)
            {
                return (rate, false);
            }

            announcedAt = now;

            return (rate, true);
        }
    }

    /// <summary>One transfer's mutable state, which only the queue touches and only under its gate.</summary>
    private sealed class Entry
    {
        public required Guid Id { get; init; }

        public required TransferDirection Direction { get; init; }

        public required string LocalPath { get; init; }

        public required string RemotePath { get; init; }

        public required string Name { get; init; }

        public required long Length { get; init; }

        public required TransferState State { get; set; }

        public long Transferred { get; set; }

        public double BytesPerSecond { get; set; }

        public string? Failure { get; set; }

        /// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
        public bool Resume { get; set; }

        public CancellationTokenSource? Cancellation { get; set; }
    }
}
