using System.Text;

namespace DodoSSH.Client.Ssh.Tests;

/// <summary>
/// The SFTP subsystem, against a real sshd.
/// </summary>
/// <remarks>
/// <para>
/// Everything this suite is about is behaviour of a server rather than of this code: whether a listing
/// carries the permission bits the design's <c>PERMS</c> column needs, whether opening at an offset really
/// starts there, and whether a rename over a name that already exists fails rather than silently replacing —
/// which the transfer queue's part-file scheme depends on. None of it can be established against a mock.
/// </para>
/// <para>
/// One directory per test, named after it, because the container is shared with every other suite in the
/// assembly and a test that cleaned up by emptying the home directory would take another test's fixture with
/// it. The <em>session</em> is shared too, and that is a limit of the server rather than tidiness — see
/// <see cref="SshServerFixture.SftpAsync"/>, which explains what opening one per test did to the rest of the
/// assembly.
/// </para>
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class SftpSessionTests(SshServerFixture fixture)
{
    private static CancellationToken Token => TestContext.Current.CancellationToken;

    [Fact]
    public async Task AnOpenedSession_StartsInTheAccountsHomeDirectory()
    {
        var sftp = await fixture.SftpAsync(Token);

        // Absolute, because the server canonicalises it during the handshake. A relative answer would make
        // every path the browser builds relative too, and the breadcrumb trail meaningless.
        SftpPath.IsAbsolute(sftp.HomeDirectory).ShouldBeTrue(sftp.HomeDirectory);
        sftp.IsConnected.ShouldBeTrue();
    }

    [Fact]
    public async Task AListing_CarriesKindSizeAndPermissions()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(sftp, nameof(AListing_CarriesKindSizeAndPermissions));
        var content = "the quick brown fox"u8.ToArray();

        await WriteAsync(sftp, SftpPath.Combine(directory, "a-file"), content);
        await sftp.CreateDirectoryAsync(SftpPath.Combine(directory, "a-directory"), Token);

        var entries = await sftp.ListAsync(directory, Token);

        // Directories first: the order this interface promises, and what the file browser shows without
        // sorting again.
        entries.Select(entry => entry.Name).ShouldBe(["a-directory", "a-file"]);

        var file = entries[1];
        file.Kind.ShouldBe(SftpEntryKind.File);
        file.Length.ShouldBe(content.Length);
        file.FullPath.ShouldBe(SftpPath.Combine(directory, "a-file"));

        // The one column nothing in this repository could render before. The exact bits depend on the
        // server's umask, so what is pinned is the shape and the kind character rather than the mode.
        file.Permissions.Length.ShouldBe(10);
        file.Permissions[0].ShouldBe('-');
        file.Permissions.ShouldStartWith("-rw");

        entries[0].Kind.ShouldBe(SftpEntryKind.Directory);
        entries[0].Permissions[0].ShouldBe('d');
    }

    [Fact]
    public async Task AListing_DropsTheDotEntries()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(sftp, nameof(AListing_DropsTheDotEntries));

        // Empty, which is the case where "." and ".." are the whole listing — so a browser that showed them
        // would present an empty directory as one holding two things.
        var entries = await sftp.ListAsync(directory, Token);

        entries.ShouldBeEmpty();
    }

    [Fact]
    public async Task ListingSomethingThatIsNotADirectory_FailsWithThePath()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(
            sftp, nameof(ListingSomethingThatIsNotADirectory_FailsWithThePath));

        var file = SftpPath.Combine(directory, "not-a-directory");
        await WriteAsync(sftp, file, "x"u8.ToArray());

        // The path is the whole point of the translation. SSH.NET's own exception for this carries the
        // server's message and nothing about which path was asked for, and the browser has to say which
        // row failed.
        var failure = await Should.ThrowAsync<SftpPathException>(async () =>
            await sftp.ListAsync(file, Token));

        failure.Path.ShouldBe(file);
    }

    [Fact]
    public async Task Stat_AnswersNullForSomethingThatIsNotThere()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(
            sftp, nameof(Stat_AnswersNullForSomethingThatIsNotThere));

        // Absent is an answer rather than a failure: the transfer queue asks this before every upload to
        // find out whether it would be overwriting something, and that question has a "no".
        (await sftp.StatAsync(SftpPath.Combine(directory, "nothing-here"), Token)).ShouldBeNull();
    }

    [Fact]
    public async Task OpeningAtAnOffset_ReadsFromThere()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(sftp, nameof(OpeningAtAnOffset_ReadsFromThere));
        var path = SftpPath.Combine(directory, "resumable");

        await WriteAsync(sftp, path, "0123456789"u8.ToArray());

        // The whole of resume, in one call. If the server ignored the offset this would read the file from
        // the start and a resumed download would silently duplicate its first half.
        var stream = await sftp.OpenReadAsync(path, 4, Token);
        await using var scope = stream.ConfigureAwait(false);

        using var reader = new StreamReader(stream, Encoding.UTF8);

        (await reader.ReadToEndAsync(Token)).ShouldBe("456789");
    }

    [Fact]
    public async Task WritingAtAnOffset_LeavesWhatWasAlreadyThere()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(sftp, nameof(WritingAtAnOffset_LeavesWhatWasAlreadyThere));
        var path = SftpPath.Combine(directory, "appended");

        await WriteAsync(sftp, path, "0123"u8.ToArray());

        var stream = await sftp.OpenWriteAsync(path, 4, Token);

        await using (stream.ConfigureAwait(false))
        {
            await stream.WriteAsync("456789"u8.ToArray(), Token);
            await stream.FlushAsync(Token);
        }

        // The other half of resume: an upload that carried on from an offset must not have truncated the
        // bytes an earlier attempt already delivered.
        (await ReadAllAsync(sftp, path)).ShouldBe("0123456789");
    }

    [Fact]
    public async Task Rename_RefusesToReplaceSomethingThatIsAlreadyThere()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(
            sftp, nameof(Rename_RefusesToReplaceSomethingThatIsAlreadyThere));

        var part = SftpPath.Combine(directory, "artefact.dodossh-part");
        var destination = SftpPath.Combine(directory, "artefact");

        await WriteAsync(sftp, part, "new"u8.ToArray());
        await WriteAsync(sftp, destination, "old"u8.ToArray());

        // The transfer queue's last step, and the assumption underneath its promise never to overwrite: it
        // checks the destination before starting, and this is what stops a file that appeared in the
        // meantime from being replaced anyway. SFTP's rename is specified not to clobber, and this is the
        // check that the server this project tests against actually behaves that way.
        await Should.ThrowAsync<SftpPathException>(async () =>
            await sftp.RenameAsync(part, destination, Token));

        (await ReadAllAsync(sftp, destination)).ShouldBe("old");
    }

    [Fact]
    public async Task DeletingANonEmptyDirectory_Fails()
    {
        var sftp = await fixture.SftpAsync(Token);

        var directory = await MakeDirectoryAsync(sftp, nameof(DeletingANonEmptyDirectory_Fails));

        await WriteAsync(sftp, SftpPath.Combine(directory, "occupant"), "x"u8.ToArray());

        // Deliberate, and the reason this interface has no recursive delete: the one destructive operation
        // on the transfers screen must not be able to take a directory tree with it.
        await Should.ThrowAsync<SftpPathException>(async () => await sftp.DeleteAsync(directory, Token));
    }

    [Fact]
    public async Task AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe()
    {
        var factory = new SshNetConnectionFactory(new InMemoryKnownHostStore());

        // File transfer opens its own connection, so it makes its own first-contact decision. The failure
        // that matters is the one this asserts is *not* raised: a bare connection error would send the user
        // looking at the network for what is a fingerprint they have not approved.
        var refusal = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
            await factory.OpenSftpAsync(Request(), Token));

        refusal.Presentation.Host.ShouldBe(fixture.Host);
        refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
    }

    private SshConnectionRequest Request() => new(
        fixture.Host,
        fixture.Port,
        SshServerFixture.Username,
        new SshPasswordCredential(SshServerFixture.Password));

    /// <summary>A directory of this test's own, under the account's home.</summary>
    private static async Task<string> MakeDirectoryAsync(ISftpSession sftp, string name)
    {
        var path = SftpPath.Combine(sftp.HomeDirectory, $"sftp-{name}");

        if (await sftp.StatAsync(path, Token) is not null)
        {
            // A previous run of the same test in a container that outlived it. Emptying it is enough:
            // nothing here creates nested directories.
            foreach (var entry in await sftp.ListAsync(path, Token))
            {
                await sftp.DeleteAsync(entry.FullPath, Token);
            }

            return path;
        }

        await sftp.CreateDirectoryAsync(path, Token);

        return path;
    }

    private static async Task WriteAsync(ISftpSession sftp, string path, byte[] content)
    {
        var stream = await sftp.OpenWriteAsync(path, 0, Token);

        await using (stream.ConfigureAwait(false))
        {
            await stream.WriteAsync(content, Token);
            await stream.FlushAsync(Token);
        }
    }

    private static async Task<string> ReadAllAsync(ISftpSession sftp, string path)
    {
        var stream = await sftp.OpenReadAsync(path, 0, Token);
        await using var scope = stream.ConfigureAwait(false);

        using var reader = new StreamReader(stream, Encoding.UTF8);

        return await reader.ReadToEndAsync(Token);
    }
}
