using System.Collections.Concurrent;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using NSubstitute;

namespace DodoSSH.Client.App.Tests;

/// <summary>
/// What may be queued for transfer, and what is said about the rest.
/// </summary>
/// <remarks>
/// <para>
/// This is the whole of what drag and drop decides. The handlers on the screen extract paths or rows from a
/// drop and hand them here; every rule about which of them can be moved, which are skipped and what the
/// status line says lives in the view model, where it needs no window.
/// </para>
/// <para>
/// <b>What these cannot cover is the drag itself.</b> Headless Avalonia has no native window and cannot
/// synthesise a platform drag, so a test that pretended to drop a file from the file manager would pass
/// while confirming nothing. The wiring is verified by hand — see <c>docs/manual-checks.md</c> — and what
/// is automated is the half that a person checking by eye would most easily get wrong: the counting.
/// </para>
/// </remarks>
public sealed class TransferQueueingTests : IDisposable
{
    private readonly string directory =
        Path.Combine(Path.GetTempPath(), $"dodossh-drop-{Guid.CreateVersion7():N}");

    /// <summary>What the view model has posted and <see cref="Queued"/> has not run yet.</summary>
    /// <remarks>
    /// A queue of this test's own, standing in for the dispatcher — which is what it replaces. Draining
    /// <c>Dispatcher.UIThread</c> meant depending on which thread a runner happened to touch that
    /// process-wide object from first, and once another class got there first every test here died on
    /// "the calling thread cannot access this object" having asserted nothing about transfers at all.
    /// <para>
    /// A queue rather than a poster that runs the action inline, and the difference is not stylistic: the
    /// transfer queue raises <c>Changed</c> from its pump thread as well as from the call that enqueued,
    /// so inline execution would have a background thread adding rows to an <c>ObservableCollection</c>
    /// while the test reads it. Draining keeps every mutation on the thread doing the asserting, which is
    /// the one thing the dispatcher was providing that is worth keeping.
    /// </para>
    /// </remarks>
    private readonly ConcurrentQueue<Action> posted = new();

    private readonly TransfersViewModel transfers;

    public TransferQueueingTests()
    {
        Directory.CreateDirectory(directory);

        transfers = new TransfersViewModel(
            Substitute.For<ISftpSessionFactory>(),
            TimeProvider.System,
            posted.Enqueue);
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (Directory.Exists(directory))
        {
            Directory.Delete(directory, recursive: true);
        }
    }

    /// <remarks>
    /// The refusal that has to happen before anything else: there is nowhere to put a file until a host is
    /// connected, and a queue that filled up first would start failing the moment one was.
    /// </remarks>
    [Fact]
    public void DroppingFilesWhileDisconnected_QueuesNothingAndSaysWhy()
    {
        transfers.IsConnected = false;

        transfers.QueueUploads([File("one.txt")]);

        Queued().ShouldBeEmpty();
        transfers.Status.ShouldContain("Connect to a host first");
    }

    [Fact]
    public void DroppingSeveralFiles_QueuesEachOfThem()
    {
        Connected();

        transfers.QueueUploads([File("one.txt"), File("two.txt"), File("three.txt")]);

        Queued().Count.ShouldBe(3);
        transfers.Status.ShouldContain("3 files");
        transfers.Status.ShouldContain("/srv/app");
    }

    /// <remarks>
    /// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
    /// looks exactly like a transfer that failed to start — so it is counted and reported.
    /// </remarks>
    [Fact]
    public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
    {
        Connected();

        var folder = Path.Combine(directory, "a-folder");
        Directory.CreateDirectory(folder);

        transfers.QueueUploads([File("one.txt"), folder]);

        Queued().ShouldHaveSingleItem();
        transfers.Status.ShouldContain("1 file");
        transfers.Status.ShouldContain("1 folder was skipped");
    }

    /// <remarks>
    /// The paths in an operating-system drop come from another process and are not obliged to still be
    /// right by the time the drop lands.
    /// </remarks>
    [Fact]
    public void DroppingAFileThatHasGone_SkipsItAndSaysSo()
    {
        Connected();

        transfers.QueueUploads([File("one.txt"), Path.Combine(directory, "never-existed.txt")]);

        Queued().ShouldHaveSingleItem();
        transfers.Status.ShouldContain("1 item was no longer there");
    }

    [Fact]
    public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
    {
        Connected();

        var folder = Path.Combine(directory, "a-folder");
        Directory.CreateDirectory(folder);

        transfers.QueueUploads([folder]);

        Queued().ShouldBeEmpty();
        transfers.Status.ShouldContain("Nothing was queued");
        transfers.Status.ShouldContain("1 folder was skipped");
    }

    [Fact]
    public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
    {
        Connected();

        transfers.QueueDownloads([RemoteFile("one.log"), RemoteFile("two.log")]);

        Queued().Count.ShouldBe(2);
        transfers.Status.ShouldContain("2 files");
        transfers.Status.ShouldContain("download into");
    }

    [Fact]
    public void DroppingARemoteDirectory_SkipsItAndSaysSo()
    {
        Connected();

        transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);

        Queued().ShouldHaveSingleItem();
        transfers.Status.ShouldContain("1 folder was skipped");
    }

    /// <remarks>
    /// The buttons were the only way to queue anything before drag and drop, and they now go through the
    /// same two methods — so there is one set of rules rather than two that have to agree. This is what
    /// says they still do.
    /// </remarks>
    [Fact]
    public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
    {
        Connected();

        transfers.SelectedRemoteEntry = RemoteFile("one.log");
        transfers.DownloadCommand.Execute(null);

        Queued().ShouldHaveSingleItem();

        // And refuses a directory in the same words, rather than with the button's own message.
        transfers.SelectedRemoteEntry = RemoteDirectory("logs");
        transfers.DownloadCommand.Execute(null);

        Queued().Count.ShouldBe(1);
        transfers.Status.ShouldContain("1 folder was skipped");
    }

    /// <remarks>
    /// Queueing is reachable from any thread — a drop is handled on the UI thread, a retry is not — and
    /// nothing about it may depend on which one. Worth stating because the version of this class that
    /// drained <c>Dispatcher.UIThread</c> did depend on exactly that, and said so only by failing in CI on
    /// a machine whose scheduling differed. The draining still happens on the test's own thread, as
    /// <see cref="Queued"/> explains; what is asserted here is the half that has no business caring.
    /// </remarks>
    [Fact]
    public void QueueingFromAnotherThread_StillEnqueues()
    {
        Exception? failure = null;

        var thread = new Thread(() =>
        {
            try
            {
                Connected();
                transfers.QueueUploads([File("one.txt")]);
            }
            catch (Exception exception)
            {
                failure = exception;
            }
        });

        thread.Start();
        thread.Join();

        failure.ShouldBeNull();
        Queued().ShouldHaveSingleItem();
    }

    /// <remarks>
    /// The phone's way in, and it has to obey the same rules as every other: a document chosen in the system
    /// picker is copied into the cache and the copy is queued, which is an upload with one extra property —
    /// that this application made the file and will delete it again. Everything about *what may be queued*
    /// is the same, and this says so rather than leaving a second path free to drift.
    /// </remarks>
    [Fact]
    public void StagedUploads_QueueUnderTheSameRulesAsAnyOther()
    {
        Connected();

        var folder = Path.Combine(directory, "a-folder");
        Directory.CreateDirectory(folder);

        transfers.QueueStagedUploads([File("picked.txt"), folder]);

        Queued().ShouldHaveSingleItem();
        transfers.Status.ShouldContain("1 file");
        transfers.Status.ShouldContain("1 folder was skipped");
    }

    /// <remarks>
    /// <para>
    /// The phone's way out. The delivery itself — copying the finished file into the document the save
    /// picker made — needs a transfer that actually runs and a picker to have made something, so it is
    /// checked by hand in <c>docs/manual-checks.md</c> phase 14. What is worth pinning here is the pair of
    /// refusals in front of it, because both would otherwise be discovered as an empty file sitting in
    /// somebody's Downloads: the picker creates the destination the moment it is dismissed, so anything
    /// this method turns away after that point has already cost a visible artefact.
    /// </para>
    /// </remarks>
    [Fact]
    public void ADeliveredDownload_QueuesTheFileAndRefusesADirectory()
    {
        var delivered = 0;

        Connected();

        transfers.QueueDeliveredDownload(
            RemoteFile("one.log"),
            Path.Combine(directory, "staged", "one.log"),
            _ =>
            {
                delivered++;
                return Task.CompletedTask;
            });

        Queued().ShouldHaveSingleItem();
        transfers.Status.ShouldContain("one.log");

        // A directory has nothing to fetch, and the message is the same one every other path on this screen
        // gives for the same mistake.
        transfers.QueueDeliveredDownload(
            RemoteDirectory("logs"),
            Path.Combine(directory, "staged", "logs"),
            _ => Task.CompletedTask);

        Queued().Count.ShouldBe(1);
        transfers.Status.ShouldContain("Only files");

        // Nothing is delivered by queueing. The callback runs when the bytes are there and not before.
        delivered.ShouldBe(0);
    }

    /// <summary>The queue's rows, once the posts that create them have been let run.</summary>
    /// <remarks>
    /// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
    /// marshals because the queue raises it from a pump thread. Nothing drains that here, so the posts are
    /// run by hand — the alternative is asserting on the status line alone, which is a string this code
    /// wrote about itself and proves nothing about anything having been enqueued.
    /// </remarks>
    private IReadOnlyList<TransferRowViewModel> Queued()
    {
        while (posted.TryDequeue(out var action))
        {
            action();
        }

        return transfers.Transfers;
    }

    private void Connected()
    {
        transfers.IsConnected = true;
        transfers.RemotePath = "/srv/app";
        transfers.LocalPath = directory;
    }

    private string File(string name)
    {
        var path = Path.Combine(directory, name);
        System.IO.File.WriteAllText(path, "contents");

        return path;
    }

    private static RemoteEntryRowViewModel RemoteFile(string name) => new(
        new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.File, 128, DateTimeOffset.UnixEpoch, "-rw-r--r--"));

    private static RemoteEntryRowViewModel RemoteDirectory(string name) => new(
        new SftpEntry(name, $"/srv/app/{name}", SftpEntryKind.Directory, 0, DateTimeOffset.UnixEpoch, "drwxr-xr-x"));
}
