using System.Security.Cryptography;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;

namespace DodoSSH.Client.Ssh.Tests;

/// <summary>
/// A real OpenSSH server in a container, with password and public-key auth both enabled.
/// </summary>
/// <remarks>
/// <para>
/// One container per assembly. Everything the SSH layer needs to be right about — PTY
/// allocation, window-change requests, host key encoding, key formats — is behaviour of a real
/// sshd, and none of it can be established against a mock.
/// </para>
/// <para>
/// The image is Alpine-based, so <c>stty</c> comes from busybox. Its <c>stty size</c> prints
/// "rows cols", which is what the resize assertions read.
/// </para>
/// </remarks>
public sealed class SshServerFixture : IAsyncLifetime
{
    /// <summary>The account tests authenticate as.</summary>
    public const string Username = "dodo";

    /// <summary>Password for password authentication.</summary>
    public const string Password = "correct-horse-battery-staple";

    private const int SshPort = 2222;

    private readonly SemaphoreSlim sftpGate = new(1, 1);

    private IContainer? container;
    private ISftpSession? sftp;

    /// <summary>Host port the container's sshd is published on.</summary>
    public ushort Port => container!.GetMappedPublicPort(SshPort);

    /// <summary>Host the container is reachable at.</summary>
    public string Host => container!.Hostname;

    /// <summary>An RSA key pair whose public half is authorized on the server.</summary>
    public RSA ClientKey { get; } = RSA.Create(3072);

    /// <inheritdoc />
    public async ValueTask InitializeAsync()
    {
        container = new ContainerBuilder("linuxserver/openssh-server:latest")
            .WithEnvironment("PUID", "1000")
            .WithEnvironment("PGID", "1000")
            .WithEnvironment("USER_NAME", Username)
            .WithEnvironment("USER_PASSWORD", Password)
            .WithEnvironment("PASSWORD_ACCESS", "true")
            .WithEnvironment("SUDO_ACCESS", "false")
            .WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
            .WithPortBinding(SshPort, assignRandomHostPort: true)

            // The entrypoint generates host keys, rewrites sshd_config and installs the authorized
            // key before sshd is usable, so a published port is not readiness. Both conditions are
            // needed: the log line proves the authorized key was installed (a connection accepted
            // before that fails public-key auth), and the port check proves sshd is actually
            // accepting. The message is this image's own wording — "Server listening on" is
            // OpenSSH's and never appears here, which is a wait that hangs rather than fails.
            .WithWaitStrategy(Wait.ForUnixContainer()
                .UntilMessageIsLogged("Public key from env variable added")
                .UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))

            .Build();

        await container.StartAsync();
    }

    /// <summary>
    /// Adds one <c>authorized_keys</c> line to the account tests authenticate as.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The fixture's own key arrives through the image's <c>PUBLIC_KEY</c> variable, which takes one. This
    /// is for the case that needs a second: proving a key <em>this client generated</em> authenticates
    /// against a real sshd, which is the only test that can establish the hand-written
    /// <c>openssh-key-v1</c> encoding is right. A parser accepting the file is weaker — SSH.NET could be
    /// forgiving about something OpenSSH is not.
    /// </para>
    /// <para>
    /// sshd re-reads <c>authorized_keys</c> on each authentication attempt, so nothing has to be restarted.
    /// </para>
    /// </remarks>
    public async ValueTask AuthorizeAsync(string publicKeyLine, CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(publicKeyLine);

        // Single-quoted in the shell and the line is base64 plus an algorithm name and a comment, so there
        // is nothing in it a quote could end. Asserted rather than assumed all the same: a silent failure
        // here would show up as an authentication error in a test whose subject is the key encoding, which
        // is the most misleading way for this to break.
        var result = await container!.ExecAsync(
            ["sh", "-c", $"echo '{publicKeyLine.Trim()}' >> /config/.ssh/authorized_keys"],
            cancellationToken);

        if (result.ExitCode != 0)
        {
            throw new InvalidOperationException(
                $"Could not install the public key in the container: {result.Stderr}");
        }
    }

    /// <summary>
    /// One file-transfer session, opened on first use and shared by every test that wants one.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
    /// sshd's <c>MaxStartups</c> drops connections at random once enough are part-way through a handshake,
    /// and this client's first contact with an unknown host is a connection deliberately <em>refused</em> at
    /// the host key — so a suite that opened its own session per test made two handshakes per test and
    /// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with
    /// "the connection was closed by the remote host", a different few each run.
    /// </para>
    /// <para>
    /// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
    /// named after itself. See <c>ISftpSession</c>, which is one channel and is used by one caller at a
    /// time.
    /// </para>
    /// </remarks>
    public async ValueTask<ISftpSession> SftpAsync(CancellationToken cancellationToken)
    {
        await sftpGate.WaitAsync(cancellationToken);

        try
        {
            if (sftp is not null)
            {
                return sftp;
            }

            var knownHosts = new InMemoryKnownHostStore();
            var factory = new SshNetConnectionFactory(knownHosts);

            var request = new SshConnectionRequest(
                Host, Port, Username, new SshPasswordCredential(Password));

            try
            {
                // Learned by being refused, which is the only way this client learns a host key.
                return sftp = await factory.OpenSftpAsync(request, cancellationToken);
            }
            catch (SshHostKeyUnknownException unknown)
            {
                await knownHosts.TrustAsync(unknown.Presentation, cancellationToken);
            }

            return sftp = await factory.OpenSftpAsync(request, cancellationToken);
        }
        finally
        {
            sftpGate.Release();
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (sftp is not null)
        {
            await sftp.DisposeAsync();
        }

        sftpGate.Dispose();

        if (container is not null)
        {
            await container.DisposeAsync();
        }

        ClientKey.Dispose();
    }

    /// <summary>
    /// Renders an RSA public key in the single-line <c>authorized_keys</c> format.
    /// </summary>
    /// <remarks>
    /// Hand-encoded because there is no BCL helper. The SSH wire format is a sequence of
    /// length-prefixed strings: the algorithm name, then the exponent, then the modulus — both
    /// as signed big-endian integers, which is why a leading zero byte is prepended when the
    /// high bit is set. Getting that wrong yields a key sshd silently ignores.
    /// </remarks>
    private static string ExportOpenSshPublicKey(RSA rsa)
    {
        var parameters = rsa.ExportParameters(includePrivateParameters: false);

        using var blob = new MemoryStream();
        WriteSshString(blob, "ssh-rsa"u8.ToArray());
        WriteSshMpint(blob, parameters.Exponent!);
        WriteSshMpint(blob, parameters.Modulus!);

        return $"ssh-rsa {Convert.ToBase64String(blob.ToArray())} dodossh-test";
    }

    private static void WriteSshString(Stream destination, byte[] value)
    {
        Span<byte> length = stackalloc byte[4];
        System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(length, (uint)value.Length);
        destination.Write(length);
        destination.Write(value);
    }

    private static void WriteSshMpint(Stream destination, byte[] value)
    {
        // Signed big-endian: a high bit set would otherwise read as negative.
        if (value.Length > 0 && (value[0] & 0x80) != 0)
        {
            var padded = new byte[value.Length + 1];
            value.CopyTo(padded, 1);
            WriteSshString(destination, padded);
            return;
        }

        WriteSshString(destination, value);
    }
}

/// <summary>Shares one SSH server across every test class in the assembly.</summary>
[CollectionDefinition(Name)]
public sealed class SshCollection : ICollectionFixture<SshServerFixture>
{
    /// <summary>Collection name.</summary>
    public const string Name = "ssh";
}
