using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.Transfer.Tests;

/// <summary>
/// The transfer queue: what ends up on disk, and what happens when a transfer stops halfway.
/// </summary>
/// <remarks>
/// Every assertion here is about a promise the queue makes in prose — nothing is written at its final name
/// until it is complete, a destination that already exists is refused rather than overwritten, an
/// interrupted transfer carries on rather than starting again, and a partial file left by something else is
/// never resumed from. Those are the four ways a file transfer can quietly deliver the wrong bytes.
/// </remarks>
public sealed class FileTransferQueueTests : IDisposable
{
    private const string RemoteDirectory = "/home/dodo";

    private readonly FakeSftpSession host = new();
    private readonly string workspace = Directory.CreateTempSubdirectory("dodossh-transfer-").FullName;

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    /// <inheritdoc />
    public void Dispose()
    {
        try
        {
            Directory.Delete(workspace, recursive: true);
        }
        catch (IOException)
        {
            // A handle the runtime has not released yet. The directory is under the system temporary path
            // and failing a passing test over it would be the wrong trade.
        }
    }

    [Fact]
    public async Task ADownload_LandsAtItsFinalNameWithNoPartFileLeftBehind()
    {
        var content = Bytes(300_000);
        host.Seed(Remote("artefact.tar"), content);

        await using var queue = NewQueue();

        var finished = await RunToCompletionAsync(
            queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);

        finished.State.ShouldBe(TransferState.Completed);
        finished.Transferred.ShouldBe(content.Length);

        (await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
        File.Exists(Local("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
    }

    [Fact]
    public async Task ADownloadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
    {
        host.Seed(Remote("artefact.tar"), Bytes(1_000));
        await File.WriteAllTextAsync(Local("artefact.tar"), "something else entirely", Token);

        await using var queue = NewQueue();

        var finished = await RunToCompletionAsync(
            queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), 1_000);

        finished.State.ShouldBe(TransferState.Failed);
        finished.Failure.ShouldNotBeNull();

        // The whole point of the refusal: what was there is still there, unread and unreplaced.
        (await File.ReadAllTextAsync(Local("artefact.tar"), Token)).ShouldBe("something else entirely");
    }

    [Fact]
    public async Task AnInterruptedDownload_CarriesOnFromWhereItStopped()
    {
        var content = Bytes(300_000);
        host.Seed(Remote("artefact.tar"), content);
        host.FailReadAfter = 100_000;

        await using var queue = NewQueue();

        var broken = await RunToCompletionAsync(
            queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);

        broken.State.ShouldBe(TransferState.Failed);
        broken.Transferred.ShouldBeInRange(1, content.Length - 1);
        broken.CanResume.ShouldBeTrue();

        // The part file is what makes this a resume rather than a restart, and it is deliberately not at the
        // destination's name — nothing may look like a finished download until it is one.
        var part = Local("artefact.tar") + FileTransferQueue.PartSuffix;
        File.Exists(part).ShouldBeTrue();
        File.Exists(Local("artefact.tar")).ShouldBeFalse();

        var partLength = new FileInfo(part).Length;

        var resumed = await AwaitFinishAsync(queue, () => queue.Retry(broken.Id));

        resumed.State.ShouldBe(TransferState.Completed);

        // Asked for the second time at the offset the part file had reached, which is the only direct
        // evidence that the bytes already moved were not moved again.
        host.ReadOffsets.ShouldBe([0, partLength]);

        (await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
    }

    [Fact]
    public async Task AFreshDownload_WillNotResumeFromAPartFileItDidNotWrite()
    {
        var content = Bytes(200_000);
        host.Seed(Remote("artefact.tar"), content);

        // Litter from something else entirely — an earlier run of the application, or an earlier attempt at
        // a file of the same name that has since changed. Resuming on the strength of the name matching is
        // how a corrupt artefact gets delivered with nothing reporting a failure.
        await File.WriteAllBytesAsync(
            Local("artefact.tar") + FileTransferQueue.PartSuffix, Bytes(50_000, seed: 7), Token);

        await using var queue = NewQueue();

        var finished = await RunToCompletionAsync(
            queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);

        finished.State.ShouldBe(TransferState.Completed);

        host.ReadOffsets.ShouldBe([0]);
        (await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
    }

    [Fact]
    public async Task AnUpload_LandsAtItsFinalNameOnTheHost()
    {
        var content = Bytes(150_000);
        await File.WriteAllBytesAsync(Local("artefact.tar"), content, Token);

        await using var queue = NewQueue();

        var finished = await RunToCompletionAsync(
            queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), content.Length);

        finished.State.ShouldBe(TransferState.Completed);

        host.Read(Remote("artefact.tar")).ShouldBe(content);
        host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
    }

    [Fact]
    public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
    {
        var existing = "the running deployment"u8.ToArray();
        host.Seed(Remote("artefact.tar"), existing);
        await File.WriteAllBytesAsync(Local("artefact.tar"), Bytes(1_000), Token);

        await using var queue = NewQueue();

        var finished = await RunToCompletionAsync(
            queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), 1_000);

        finished.State.ShouldBe(TransferState.Failed);
        host.Read(Remote("artefact.tar")).ShouldBe(existing);
    }

    [Fact]
    public async Task TransfersRunOneAtATime()
    {
        for (var i = 0; i < 4; i++)
        {
            host.Seed(Remote($"file-{i}"), Bytes(200_000, seed: i));
        }

        await using var queue = NewQueue();

        var running = new HashSet<Guid>();
        var most = 0;
        var gate = new Lock();

        queue.Changed += (_, e) =>
        {
            lock (gate)
            {
                if (e.Transfer.State is TransferState.Running)
                {
                    running.Add(e.Transfer.Id);
                }
                else
                {
                    running.Remove(e.Transfer.Id);
                }

                most = Math.Max(most, running.Count);
            }
        };

        var ids = new List<Guid>();

        for (var i = 0; i < 4; i++)
        {
            ids.Add(queue.Enqueue(
                TransferDirection.Download, Local($"file-{i}"), Remote($"file-{i}"), 200_000));
        }

        await WaitForQuietAsync(queue);

        queue.Snapshot().ShouldAllBe(transfer => transfer.State == TransferState.Completed);

        // The claim the whole design rests on: one channel, one transfer, so the throughput on a row is the
        // throughput of the link rather than a share of it.
        lock (gate)
        {
            most.ShouldBe(1);
        }

        ids.Count.ShouldBe(4);
    }

    [Fact]
    public async Task CancellingAQueuedTransfer_TakesItOutOfTheQueueWithoutStartingIt()
    {
        host.Seed(Remote("slow"), Bytes(2_000_000));
        host.Seed(Remote("never"), Bytes(1_000));

        await using var queue = NewQueue();

        queue.Enqueue(TransferDirection.Download, Local("slow"), Remote("slow"), 2_000_000);
        var second = queue.Enqueue(TransferDirection.Download, Local("never"), Remote("never"), 1_000);

        queue.Cancel(second);

        await WaitForQuietAsync(queue);

        var cancelled = queue.Snapshot().Single(transfer => transfer.Id == second);

        cancelled.State.ShouldBe(TransferState.Cancelled);
        cancelled.Transferred.ShouldBe(0);
        File.Exists(Local("never")).ShouldBeFalse();
    }

    [Fact]
    public async Task DiscardingAStoppedDownload_TakesItsPartFileWithIt()
    {
        var content = Bytes(200_000);
        host.Seed(Remote("artefact.tar"), content);
        host.FailReadAfter = 60_000;

        await using var queue = NewQueue();

        var broken = await RunToCompletionAsync(
            queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);

        var part = Local("artefact.tar") + FileTransferQueue.PartSuffix;
        File.Exists(part).ShouldBeTrue();

        (await queue.DiscardAsync(broken.Id, Token)).ShouldBeTrue();

        // The row was the only thing that knew the part file existed, so removing one without the other
        // would leave bytes on disk nobody could attribute.
        queue.Snapshot().ShouldBeEmpty();
        File.Exists(part).ShouldBeFalse();
    }

    [Fact]
    public async Task ATransferWithNoSessionToRunOn_FailsOnItsOwnRowRatherThanSilently()
    {
        await using var queue = new FileTransferQueue(
            _ => Task.FromException<IRemoteFileStore>(new IOException("the host is not reachable")),
            TimeProvider.System);

        var finished = await RunToCompletionAsync(
            queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), 10);

        finished.State.ShouldBe(TransferState.Failed);
        finished.Failure.ShouldBe("the host is not reachable");
    }

    private FileTransferQueue NewQueue() =>
        new(_ => Task.FromResult<IRemoteFileStore>(host), TimeProvider.System);

    private string Local(string name) => Path.Combine(workspace, name);

    private static string Remote(string name) => SftpPath.Combine(RemoteDirectory, name);

    /// <remarks>
    /// Repeatable rather than random, so a failure can be reproduced, and not all one byte, so a resumed
    /// transfer that started from the wrong offset produces a file that differs rather than one that happens
    /// to match.
    /// </remarks>
    private static byte[] Bytes(int length, int seed = 0)
    {
        var content = new byte[length];

        for (var i = 0; i < length; i++)
        {
            content[i] = (byte)((i + seed) % 251);
        }

        return content;
    }

    private static Task<TransferSnapshot> RunToCompletionAsync(
        FileTransferQueue queue,
        TransferDirection direction,
        string localPath,
        string remotePath,
        long length) =>
        AwaitFinishAsync(queue, () => queue.Enqueue(direction, localPath, remotePath, length));

    /// <summary>
    /// Subscribes, starts a transfer, and completes when a transfer reaches a state it will not leave.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The subscription goes on <em>before</em> the transfer starts, because the pump runs on a thread-pool
    /// thread: a small transfer can be finished before <c>Enqueue</c> has returned its id, so subscribing
    /// afterwards would wait for an event that has already happened.
    /// </para>
    /// <para>
    /// Which is also why it does not match on the id — there is nothing to match against yet. Every caller
    /// has exactly one transfer in flight, which is what makes "a transfer finished" and "this transfer
    /// finished" the same statement. The one test with several running uses
    /// <see cref="WaitForQuietAsync"/> instead.
    /// </para>
    /// </remarks>
    private static async Task<TransferSnapshot> AwaitFinishAsync(FileTransferQueue queue, Action start)
    {
        var finished = new TaskCompletionSource<TransferSnapshot>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        void OnChanged(object? sender, TransferChangedEventArgs e)
        {
            if (e.Transfer.IsFinished)
            {
                finished.TrySetResult(e.Transfer);
            }
        }

        queue.Changed += OnChanged;

        try
        {
            start();

            return await finished.Task.WaitAsync(TimeSpan.FromSeconds(30), Token);
        }
        finally
        {
            queue.Changed -= OnChanged;
        }
    }

    /// <summary>Waits until nothing is queued or running.</summary>
    private static async Task WaitForQuietAsync(FileTransferQueue queue)
    {
        var deadline = TimeSpan.FromSeconds(30);
        var waited = TimeSpan.Zero;

        while (queue.IsBusy && waited < deadline)
        {
            await Task.Delay(20, Token);
            waited += TimeSpan.FromMilliseconds(20);
        }

        queue.IsBusy.ShouldBeFalse("the queue should have drained");
    }
}
