using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.ObjectStore;

/// <summary>
/// One S3-compatible bucket, as a place with files in it.
/// </summary>
/// <remarks>
/// <para>
/// <b>A bucket is not a filesystem, and the three places that matter are documented on the members rather
/// than smoothed over.</b> There are no directories, only keys with slashes in them; an object cannot be
/// appended to, so an interrupted upload cannot resume; and there is no rename, only copy-then-delete. Each
/// is refused with a reason or implemented with its cost stated, because a file browser that quietly did
/// something adjacent would be worse than one that said no.
/// </para>
/// <para>
/// <b>Listings are one page.</b> <c>ListObjectsV2</c> returns up to a thousand keys and this asks for one
/// page, so a prefix with more than that in it is shown truncated — which the screen says out loud. Paging
/// the whole way through a bucket with a million objects under one prefix is a request storm behind a
/// scrollbar nobody asked for; the filter box is the answer, and a prefix that large is not a directory
/// anybody browses.
/// </para>
/// </remarks>
internal sealed class S3FileStore : IRemoteFileStore
{
    /// <summary>
    /// The most keys one listing asks for.
    /// </summary>
    /// <remarks>
    /// The service's own maximum. Asking for less would page more often for no benefit; asking for more is
    /// not possible.
    /// </remarks>
    private const int PageSize = 1000;

    private readonly IAmazonS3 client;
    private readonly string bucket;
    private int disposed;

    internal S3FileStore(IAmazonS3 client, string bucket)
    {
        this.client = client;
        this.bucket = bucket;
    }

    /// <summary>
    /// Always true, because there is no connection to be up.
    /// </summary>
    /// <remarks>
    /// S3 is request-per-operation over HTTPS; there is no session to drop and nothing to poll. Answering
    /// false when the network is down would be a claim this type cannot make without a request of its own,
    /// and every operation already reports its own failure.
    /// </remarks>
    public bool IsConnected => Volatile.Read(ref disposed) == 0;

    /// <inheritdoc />
    public string HomeDirectory => ObjectKeys.Root;

    /// <summary>
    /// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files.
    /// </summary>
    /// <remarks>
    /// The delimiter is what makes this a directory listing rather than a recursive walk — without it, a
    /// listing of the root returns every object in the bucket. Common prefixes come back as directories;
    /// the marker object some tools write for a "folder" is dropped, because it is the directory itself and
    /// showing it would put an empty-named row inside every one.
    /// </remarks>
    public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(path);

        var prefix = ObjectKeys.ToPrefix(path);

        ListObjectsV2Response response;
        try
        {
            response = await client.ListObjectsV2Async(
                new ListObjectsV2Request
                {
                    BucketName = bucket,
                    Prefix = prefix,
                    Delimiter = "/",
                    MaxKeys = PageSize,
                },
                cancellationToken).ConfigureAwait(false);
        }
        catch (AmazonS3Exception exception)
        {
            throw new SftpPathException(path, Describe(exception), exception);
        }

        return Project(response, prefix);
    }

    /// <summary>Turns one listing into rows a file browser can show.</summary>
    /// <remarks>
    /// Directories first and then by name, which is the order every caller of this interface expects and
    /// what saves the screen sorting it again.
    /// </remarks>
    private static IReadOnlyList<SftpEntry> Project(ListObjectsV2Response response, string prefix)
    {
        var entries = new List<SftpEntry>();

        foreach (var common in response.CommonPrefixes ?? [])
        {
            entries.Add(new SftpEntry(
                ObjectKeys.NameOf(common),
                ObjectKeys.ToPath(common),
                SftpEntryKind.Directory,
                Length: 0,
                LastWriteTimeUtc: default,

                // Blank rather than invented. A bucket has no POSIX mode, and printing drwxr-xr-x beside a
                // prefix would be a fact this store made up.
                Permissions: string.Empty));
        }

        foreach (var item in response.S3Objects ?? [])
        {
            // The marker object for this prefix itself, which several tools write to make a folder appear
            // in a web console. It is this directory, not something in it.
            if (string.Equals(item.Key, prefix, StringComparison.Ordinal))
            {
                continue;
            }

            entries.Add(new SftpEntry(
                ObjectKeys.NameOf(item.Key),
                ObjectKeys.ToPath(item.Key),
                SftpEntryKind.File,
                item.Size ?? 0,
                Utc(item.LastModified),
                Permissions: string.Empty));
        }

        return
        [
            .. entries
                .OrderByDescending(entry => entry.Kind is SftpEntryKind.Directory)
                .ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase),
        ];
    }

    /// <summary>
    /// The SDK's timestamp as an unambiguous instant.
    /// </summary>
    /// <remarks>
    /// Stated rather than converted implicitly. S3 returns <c>Last-Modified</c> in UTC and the SDK hands it
    /// over as a <see cref="DateTime"/> whose <c>Kind</c> is not reliably set — so an implicit conversion
    /// would read it as local time on some paths and shift every timestamp in the listing by the machine's
    /// offset. The file browser shows this column beside an SFTP one.
    /// </remarks>
    private static DateTimeOffset Utc(DateTime? moment) =>
        moment is { } value
            ? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
            : default;

    /// <summary>
    /// What one path is, or null when nothing is there.
    /// </summary>
    /// <remarks>
    /// Two requests in the worst case, because a bucket cannot answer "is this a directory" directly: a
    /// HEAD tells us whether an object with that exact key exists, and only a listing can tell us whether
    /// anything lives under it as a prefix. The order matters — a key can be both, and the object is the
    /// more specific answer.
    /// </remarks>
    public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(path);

        var key = ObjectKeys.ToKey(path);

        if (key.Length == 0)
        {
            return new SftpEntry(
                string.Empty, ObjectKeys.Root, SftpEntryKind.Directory, 0, default, string.Empty);
        }

        try
        {
            var head = await client.GetObjectMetadataAsync(
                new GetObjectMetadataRequest { BucketName = bucket, Key = key },
                cancellationToken).ConfigureAwait(false);

            return new SftpEntry(
                ObjectKeys.NameOf(key),
                ObjectKeys.ToPath(key),
                SftpEntryKind.File,
                head.ContentLength,
                Utc(head.LastModified),
                Permissions: string.Empty);
        }
        catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            // Not an object. It may still be a prefix with things under it, which is what a browser means
            // by a directory.
        }

        var listing = await client.ListObjectsV2Async(
            new ListObjectsV2Request
            {
                BucketName = bucket,
                Prefix = ObjectKeys.ToPrefix(path),
                MaxKeys = 1,
            },
            cancellationToken).ConfigureAwait(false);

        return listing.KeyCount > 0
            ? new SftpEntry(
                ObjectKeys.NameOf(key),
                ObjectKeys.ToPath(key),
                SftpEntryKind.Directory,
                0,
                default,
                string.Empty)
            : null;
    }

    /// <inheritdoc />
    public async Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(path);
        ArgumentOutOfRangeException.ThrowIfNegative(offset);

        var request = new GetObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(path) };

        if (offset > 0)
        {
            // A ranged GET, which is what makes an interrupted download resumable — and the one place where
            // a bucket is better at this than SFTP, because the range is part of the protocol rather than a
            // seek on an open handle.
            request.ByteRange = new ByteRange(offset, long.MaxValue);
        }

        try
        {
            var response = await client.GetObjectAsync(request, cancellationToken).ConfigureAwait(false);

            return response.ResponseStream;
        }
        catch (AmazonS3Exception exception)
        {
            throw new SftpPathException(path, Describe(exception), exception);
        }
    }

    /// <summary>
    /// Opens an object for writing, from the beginning.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>A non-zero offset is refused, and this is the one capability a bucket genuinely does not have.</b>
    /// Objects are immutable: there is no append, and no way to write into the middle of one. Multipart
    /// upload can rebuild an interrupted transfer, but only by keeping the upload id and every part's ETag
    /// across the interruption — state this store would have to persist somewhere, on behalf of a queue that
    /// already has its own idea of what resuming means. Refusing with a reason is the honest answer;
    /// silently starting from zero would corrupt a resumed file.
    /// </para>
    /// <para>
    /// The returned stream is the writing half of a pipe. A background upload reads the other half and
    /// chunks it into parts, so a large file never lands on disk twice and memory stays bounded by the part
    /// size — which is what the alternative, buffering to a temporary file and putting it afterwards, would
    /// have cost.
    /// </para>
    /// </remarks>
    public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(path);

        if (offset != 0)
        {
            throw new SftpPathException(
                path,
                "An object cannot be written to from the middle, so an interrupted upload to a bucket "
                + "starts again rather than resuming.");
        }

        return Task.FromResult<Stream>(
            new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken));
    }

    /// <summary>
    /// Creates the marker object that makes an empty prefix visible.
    /// </summary>
    /// <remarks>
    /// A zero-byte object whose key ends in <c>/</c>, which is the convention every S3 console and most
    /// tools use. It is not a directory — nothing in the service knows what one is — and it disappears by
    /// itself once real objects live under the prefix, which is why the listing above drops it.
    /// </remarks>
    public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(path);

        var prefix = ObjectKeys.ToPrefix(path);

        if (prefix.Length == 0)
        {
            throw new SftpPathException(path, "The root of a bucket already exists.");
        }

        try
        {
            await client.PutObjectAsync(
                new PutObjectRequest
                {
                    BucketName = bucket,
                    Key = prefix,
                    ContentBody = string.Empty,
                },
                cancellationToken).ConfigureAwait(false);
        }
        catch (AmazonS3Exception exception)
        {
            throw new SftpPathException(path, Describe(exception), exception);
        }
    }

    /// <summary>
    /// Deletes one object, or an empty prefix's marker.
    /// </summary>
    /// <remarks>
    /// Deliberately not recursive, matching SFTP's own rule and for the same reason: a recursive delete
    /// against a bucket is the one operation on this screen that can destroy something no undo reaches. A
    /// prefix with anything under it is refused and says so.
    /// </remarks>
    public async Task DeleteAsync(string path, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(path);

        var key = ObjectKeys.ToKey(path);

        if (key.Length == 0)
        {
            throw new SftpPathException(path, "A bucket cannot delete its own root.");
        }

        if (await StatAsync(path, cancellationToken).ConfigureAwait(false) is { Kind: SftpEntryKind.Directory })
        {
            var listing = await client.ListObjectsV2Async(
                new ListObjectsV2Request
                {
                    BucketName = bucket,
                    Prefix = ObjectKeys.ToPrefix(path),
                    MaxKeys = 2,
                },
                cancellationToken).ConfigureAwait(false);

            // One key is the marker object for this prefix itself; anything more is contents.
            if (listing.KeyCount > 1)
            {
                throw new SftpPathException(
                    path, "There are still objects under this prefix, so it was not deleted.");
            }

            key = ObjectKeys.ToPrefix(path);
        }

        try
        {
            await client.DeleteObjectAsync(
                new DeleteObjectRequest { BucketName = bucket, Key = key },
                cancellationToken).ConfigureAwait(false);
        }
        catch (AmazonS3Exception exception)
        {
            throw new SftpPathException(path, Describe(exception), exception);
        }
    }

    /// <summary>
    /// Copies to the new key and deletes the old one, which is what a bucket has instead of rename.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Not atomic, and it cannot be. Between the two requests both keys exist; if the delete fails, both
    /// still do. The copy is server-side — no bytes come to this machine — so the window is short, but it is
    /// real and a failure leaves a duplicate rather than a loss, which is the safe direction.
    /// </para>
    /// <para>
    /// Only objects. Renaming a prefix means copying every key under it, which is a bulk operation wearing
    /// a rename's clothing, and the failure mode is a half-moved directory.
    /// </para>
    /// </remarks>
    public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(fromPath);
        ArgumentNullException.ThrowIfNull(toPath);

        if (await StatAsync(fromPath, cancellationToken).ConfigureAwait(false)
            is not { Kind: SftpEntryKind.File })
        {
            throw new SftpPathException(
                fromPath,
                "Only an object can be renamed in a bucket. A prefix would have to be copied key by key.");
        }

        try
        {
            await client.CopyObjectAsync(
                new CopyObjectRequest
                {
                    SourceBucket = bucket,
                    SourceKey = ObjectKeys.ToKey(fromPath),
                    DestinationBucket = bucket,
                    DestinationKey = ObjectKeys.ToKey(toPath),
                },
                cancellationToken).ConfigureAwait(false);

            await client.DeleteObjectAsync(
                new DeleteObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(fromPath) },
                cancellationToken).ConfigureAwait(false);
        }
        catch (AmazonS3Exception exception)
        {
            throw new SftpPathException(fromPath, Describe(exception), exception);
        }
    }

    /// <inheritdoc />
    public ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref disposed, 1) == 0)
        {
            client.Dispose();
        }

        return ValueTask.CompletedTask;
    }

    /// <summary>
    /// What went wrong, in words that name the bucket rather than the protocol.
    /// </summary>
    /// <remarks>
    /// The SDK's own messages are accurate and unhelpful at a file browser: "The specified key does not
    /// exist" is fine, and "Access Denied" against a bucket somebody has just typed the keys for is the
    /// moment to say which of the two is more likely.
    /// </remarks>
    private static string Describe(AmazonS3Exception exception) => exception.StatusCode switch
    {
        System.Net.HttpStatusCode.NotFound => "There is nothing at that key.",
        System.Net.HttpStatusCode.Forbidden =>
            "The bucket refused that. Check the access key and what it is allowed to do.",
        System.Net.HttpStatusCode.BadRequest when exception.ErrorCode is "AuthorizationHeaderMalformed" =>
            "The bucket is in a different region to the one configured.",
        _ => exception.Message,
    };
}
