using Avalonia.Controls;
using Avalonia.Platform.Storage;

namespace DodoSSH.Client.Android.Platform;

/// <summary>
/// Turns documents chosen in the system picker into ordinary local files the transfer queue can upload.
/// </summary>
/// <remarks>
/// <para>
/// <b>The way in, and the only one this head has.</b> Android has no browsable local filesystem for a
/// second pane to show — the decision docs/android-port.md took before any of this was built — so a file
/// leaves this phone by being pointed at in the system picker, which hands back a <c>content://</c> URI
/// belonging to whichever app owns the document.
/// </para>
/// <para>
/// <b>Copied rather than streamed, and that is a requirement rather than a shortcut.</b> A document URI has
/// no path behind it, its stream is not promised to be seekable, and the grant that opens it can be revoked
/// or the document edited while an upload is in flight. <c>FileTransferQueue</c> needs all three of the
/// things that costs: a path, a length it can trust, and a seek so a resumed upload starts from the byte
/// the last attempt reached. So the document is copied into this application's own cache first and the copy
/// is what gets queued — a real file, behaving like every other thing in that queue.
/// </para>
/// <para>
/// <b>One directory per file, named by a UUIDv7.</b> Two documents chosen in one go can have the same
/// display name, and two picks a minute apart certainly can; a shared staging directory would make the
/// second copy overwrite the first, which is a data-loss bug that only shows up when somebody uploads two
/// files called <c>config</c>. The directory is the uniqueness, so the file inside it can keep the name the
/// picker gave it — which is the name the remote end gets, because <c>QueueUploads</c> takes it from the
/// path.
/// </para>
/// </remarks>
internal static class DocumentStaging
{
    /// <summary>Everything staged in either direction, under one directory so a sweep is one call.</summary>
    private static string Root =>
        Path.Combine(PhoneEnvironment.CacheDirectory, "staging");

    /// <summary>A path in the staging area for a file of this name, with the directory made.</summary>
    /// <remarks>
    /// <b>One directory per file, named by a UUIDv7</b> — see the type's own remarks for why the uniqueness
    /// is the directory rather than the name. Used by both directions: a document copied in for upload, and
    /// a download on its way out to the document the save picker made.
    /// </remarks>
    internal static string NewStagingPath(string? name)
    {
        var folder = Path.Combine(Root, Guid.CreateVersion7().ToString("n"));
        Directory.CreateDirectory(folder);

        return Path.Combine(folder, SafeName(name));
    }

    /// <summary>
    /// Asks for documents and copies each one into the cache, returning the paths of the copies.
    /// </summary>
    /// <remarks>
    /// Multiple by design: the queue moves one file at a time, but choosing them is a trip out to another
    /// application and back, and making somebody take that trip once per file is the kind of thing a phone
    /// is judged on. An empty list means the picker was dismissed, which is not an error and is reported as
    /// nothing having happened rather than as a failure.
    /// </remarks>
    internal static async Task<IReadOnlyList<string>> PickAsync(
        TopLevel top,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(top);

        var chosen = await top.StorageProvider
            .OpenFilePickerAsync(new FilePickerOpenOptions
            {
                Title = "Files to upload",
                AllowMultiple = true,
            })
            .ConfigureAwait(true);

        if (chosen.Count == 0)
        {
            return [];
        }

        var staged = new List<string>(chosen.Count);

        foreach (var document in chosen)
        {
            staged.Add(await CopyInAsync(document, cancellationToken).ConfigureAwait(true));
        }

        return staged;
    }

    /// <summary>
    /// Asks where a download should end up, and returns the document the picker made.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <c>ACTION_CREATE_DOCUMENT</c>, which is the only way a file leaves this application: everything else
    /// on this phone is either the app's own private storage or somewhere it has no permission to write.
    /// The suggested name is the remote file's, because that is the name the person was looking at when
    /// they pressed the button, and they can change it in the picker like any other save.
    /// </para>
    /// <para>
    /// <b>The document exists as soon as this returns</b> — the picker creates it, empty, and a download
    /// that then fails leaves that empty file behind. Nothing here can prevent it: the alternative is
    /// raising the picker after the transfer, minutes later, over whatever the person moved on to, and on
    /// Android often while the application is backgrounded and cannot show one at all.
    /// </para>
    /// </remarks>
    internal static async Task<IStorageFile?> PickDestinationAsync(TopLevel top, string name)
    {
        ArgumentNullException.ThrowIfNull(top);

        return await top.StorageProvider
            .SaveFilePickerAsync(new FilePickerSaveOptions
            {
                Title = "Save file",
                SuggestedFileName = name,
                ShowOverwritePrompt = true,
            })
            .ConfigureAwait(true);
    }

    /// <summary>Copies a finished download out to the document the picker made.</summary>
    /// <remarks>
    /// The write is truncating rather than appending, which matters on a retry: the picker's document is
    /// created when it is dismissed and a second attempt writes over the empty — or partly written — file
    /// rather than after it.
    /// </remarks>
    internal static async Task DeliverAsync(IStorageFile destination, string localPath)
    {
        ArgumentNullException.ThrowIfNull(destination);

        var source = new FileStream(
            localPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true);

        await using (source.ConfigureAwait(false))
        {
            var target = await destination.OpenWriteAsync().ConfigureAwait(false);

            await using (target.ConfigureAwait(false))
            {
                if (target.CanSeek)
                {
                    target.SetLength(0);
                }

                await source.CopyToAsync(target).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Deletes everything left in the staging directory.
    /// </summary>
    /// <remarks>
    /// Called once at composition, before anything can have queued a transfer, which is what makes deleting
    /// the lot safe: at that moment nothing in there belongs to a transfer that could still want it. What it
    /// is for is the residue a stopped upload leaves deliberately — the copy is kept so RESUME has something
    /// to read — a download whose delivery failed, and whatever a process death left behind mid-copy.
    /// </remarks>
    internal static void Sweep()
    {
        try
        {
            if (Directory.Exists(Root))
            {
                Directory.Delete(Root, recursive: true);
            }
        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
        {
            // A cache that could not be emptied is not a reason to refuse to start. Android reclaims this
            // directory itself when the device runs short of storage.
        }
    }

    private static async Task<string> CopyInAsync(IStorageFile document, CancellationToken cancellationToken)
    {
        var path = NewStagingPath(document.Name);

        // Off the interface thread from here down — this is a byte copy of something that can be a hundred
        // megabytes, and nothing in it touches a control. The caller's own await is what comes back to the
        // interface thread to queue the result. Hence the two-step disposal: configuring the await on a
        // using declaration would leave the variable a ConfiguredAsyncDisposable rather than a stream.
        var source = await document.OpenReadAsync().ConfigureAwait(false);

        await using (source.ConfigureAwait(false))
        {
            var target = new FileStream(
                path, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true);

            await using (target.ConfigureAwait(false))
            {
                await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
            }
        }

        return path;
    }

    /// <summary>
    /// The display name reduced to something that can be a file name here and a name on the remote.
    /// </summary>
    /// <remarks>
    /// A picker's display name comes from whichever application owns the document and is not obliged to be
    /// a valid file name — it can carry a separator, which without this would write outside the directory
    /// staging just made, and would then be joined onto the remote path by <c>QueueUploads</c>. Both are
    /// worth refusing at the one point where the name enters this application.
    /// </remarks>
    private static string SafeName(string? name)
    {
        var trimmed = Path.GetFileName(name ?? string.Empty).Trim();

        if (trimmed.Length == 0 || trimmed is "." or "..")
        {
            return "file";
        }

        return string.Join('_', trimmed.Split(Path.GetInvalidFileNameChars()));
    }
}
