using System.IO.Pipelines;
using Amazon.S3;
using Amazon.S3.Transfer;

namespace DodoSSH.Client.ObjectStore;

/// <summary>
/// A stream you write an object into.
/// </summary>
/// <remarks>
/// <para>
/// <b>The direction is the whole problem.</b> The transfer queue asks for somewhere to write and then copies
/// a local file into it; the S3 SDK wants a stream it can read from. Something has to bridge the two, and
/// there are only three ways to do it: buffer the whole object to a temporary file and upload afterwards
/// (correct, and doubles the disk a big upload costs), hold it in memory (correct until somebody uploads a
/// disc image), or run the upload concurrently and hand back the writing half of a pipe.
/// </para>
/// <para>
/// This is the third. <see cref="TransferUtility"/> reads the pipe and splits it into multipart chunks, so
/// memory stays bounded by the part size however large the object is, and nothing lands on disk twice.
/// </para>
/// <para>
/// <b>Completion is on <see cref="DisposeAsync"/>, and it is not optional.</b> The upload is only finished
/// when the pipe is completed and the background task has been awaited — so a caller that abandons this
/// stream without disposing it leaves an upload running against a bucket. That is the same contract every
/// stream has; it is written down because the consequence here is remote rather than local.
/// </para>
/// <para>
/// <b>A failed upload has to surface at the writer.</b> If the service refuses halfway, the reading half
/// stops and this stream's next <c>WriteAsync</c> would otherwise block for ever — so the background task's
/// completion also completes the pipe's reader with the exception, which is what makes the write throw with
/// the real reason rather than hang.
/// </para>
/// </remarks>
internal sealed class S3UploadStream : Stream
{
    private readonly Pipe pipe = new();
    private readonly Task upload;
    private readonly CancellationToken cancellationToken;
    private int disposed;

    internal S3UploadStream(
        IAmazonS3 client,
        string bucket,
        string key,
        CancellationToken cancellationToken)
    {
        this.cancellationToken = cancellationToken;

        upload = UploadAsync(client, bucket, key);
    }

    /// <inheritdoc />
    public override bool CanRead => false;

    /// <inheritdoc />
    public override bool CanSeek => false;

    /// <inheritdoc />
    public override bool CanWrite => Volatile.Read(ref disposed) == 0;

    /// <summary>Not answerable: an object's length is not known until it has all been written.</summary>
    public override long Length => throw new NotSupportedException();

    /// <inheritdoc cref="Length" />
    public override long Position
    {
        get => throw new NotSupportedException();
        set => throw new NotSupportedException();
    }

    /// <inheritdoc />
    public override async ValueTask WriteAsync(
        ReadOnlyMemory<byte> buffer,
        CancellationToken cancellationToken = default)
    {
        var result = await pipe.Writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);

        if (result.IsCompleted)
        {
            // The reader has stopped, which means the upload ended — almost always because the service
            // refused it. Awaiting the task surfaces that exception here, at the write, instead of leaving
            // the caller to discover it at disposal after copying a whole file into nothing.
            await upload.ConfigureAwait(false);
        }
    }

    /// <summary>
    /// Refused: this stream is asynchronous all the way down.
    /// </summary>
    /// <remarks>
    /// Blocking on the pipe from a synchronous write is a deadlock waiting for a thread-pool starvation to
    /// find it — the other half of the pipe is being read by a task that needs a thread to run on. The only
    /// caller is the transfer queue, which copies asynchronously, so this is unreachable rather than
    /// inconvenient. Throwing says which; blocking would say nothing until a large upload hung.
    /// </remarks>
    public override void Write(byte[] buffer, int offset, int count) =>
        throw new NotSupportedException(
            "An upload to a bucket is written asynchronously; use WriteAsync.");

    /// <summary>
    /// Nothing, deliberately.
    /// </summary>
    /// <remarks>
    /// A flush cannot mean what a caller would want it to here — the object does not exist until the upload
    /// completes, so there is no partial state to make durable. The pipe's own writes are already handed to
    /// the reader as they arrive.
    /// </remarks>
    public override void Flush()
    {
    }

    /// <inheritdoc cref="Flush" />
    public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;

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

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

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

    /// <inheritdoc />
    public override async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref disposed, 1) == 1)
        {
            return;
        }

        // Completing the writer is what tells the upload there is no more, so it must happen before the
        // await — and it must happen even when the caller is abandoning a failed transfer, or the background
        // task never ends.
        await pipe.Writer.CompleteAsync().ConfigureAwait(false);

        try
        {
            await upload.ConfigureAwait(false);
        }
        finally
        {
            await base.DisposeAsync().ConfigureAwait(false);
        }
    }

    /// <summary>
    /// Refused when it would have to finish an upload.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Completing this stream means completing the pipe and awaiting the upload, and doing that from a
    /// synchronous <c>Dispose</c> is the deadlock the synchronous <c>Write</c> above avoids. The alternative
    /// — completing the writer and abandoning the task — silently drops whatever the service was about to
    /// say, including a refusal, and reports a transfer as finished that never landed.
    /// </para>
    /// <para>
    /// So a <c>using</c> rather than an <c>await using</c> throws, which is loud, immediate and correct. The
    /// only caller already uses <c>await using</c>; this is what stops a second one being written by
    /// accident.
    /// </para>
    /// </remarks>
    protected override void Dispose(bool disposing)
    {
        if (disposing && Volatile.Read(ref disposed) == 0)
        {
            throw new NotSupportedException(
                "An upload to a bucket finishes asynchronously; use await using rather than using.");
        }

        base.Dispose(disposing);
    }

    private async Task UploadAsync(IAmazonS3 client, string bucket, string key)
    {
        using var transfer = new TransferUtility(client);

        try
        {
            await transfer.UploadAsync(
                new TransferUtilityUploadRequest
                {
                    BucketName = bucket,
                    Key = key,
                    InputStream = pipe.Reader.AsStream(),

                    // The stream has no length, so the utility has to be told not to look for one. It reads
                    // until the pipe completes and splits what it read into parts.
                    AutoCloseStream = false,
                },
                cancellationToken).ConfigureAwait(false);

            await pipe.Reader.CompleteAsync().ConfigureAwait(false);
        }
        catch (Exception exception)
        {
            // Completing the reader *with* the exception is what unblocks a writer that is still copying:
            // its next write sees a completed pipe and awaits this task, which rethrows this.
            await pipe.Reader.CompleteAsync(exception).ConfigureAwait(false);
            throw;
        }
    }
}
