using Renci.SshNet;
using Renci.SshNet.Common;
using Renci.SshNet.Sftp;

namespace DodoSSH.Client.Ssh;

/// <summary>An SSH.NET-backed file-transfer session.</summary>
/// <remarks>
/// Thin on purpose. Everything above this reasons about <see cref="SftpEntry"/> and <see cref="Stream"/>,
/// which is what keeps the transfer queue and the file browser testable without a server — and the one thing
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
/// caller can act on.
/// </remarks>
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
    : ISftpSession
{
    /// <inheritdoc />
    public bool IsConnected => client.IsConnected;

    /// <inheritdoc />
    public HostKeyPresentation HostKey { get; } = hostKey;

    /// <inheritdoc />
    /// <remarks>
    /// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
    /// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
    /// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
    /// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
    /// </remarks>
    public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;

    /// <inheritdoc />
    public string HomeDirectory { get; } = homeDirectory;

    /// <inheritdoc />
    public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
    {
        var entries = new List<SftpEntry>();

        try
        {
            await foreach (var file in client
                .ListDirectoryAsync(path, cancellationToken)
                .ConfigureAwait(false))
            {
                // Every directory has these and no user is choosing between them. The way up is the
                // breadcrumb trail, which cannot be mistaken for a file.
                if (file.Name is "." or "..")
                {
                    continue;
                }

                entries.Add(Describe(file));
            }
        }
        catch (Exception exception) when (IsPathFailure(exception))
        {
            throw Translate(path, exception);
        }

        // Directories first and then by name, which is the order a file browser shows and the order that
        // saves every caller sorting it again. Ordinal, because a remote filesystem's names are bytes the
        // server never claimed a culture for, and a listing whose order depended on this machine's locale
        // would put the same directory in two orders on two of a user's machines.
        entries.Sort(static (left, right) => left.Kind == right.Kind
            ? string.CompareOrdinal(left.Name, right.Name)
            : Rank(left.Kind).CompareTo(Rank(right.Kind)));

        return entries;
    }

    /// <inheritdoc />
    public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
    {
        try
        {
            var file = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);

            return Describe(file);
        }
        catch (SftpPathNotFoundException)
        {
            // Absent is an answer rather than a failure. Every caller here is asking whether something is
            // already there, and turning "no" into an exception would put a try/catch at each of them.
            return null;
        }
        catch (Exception exception) when (IsPathFailure(exception))
        {
            throw Translate(path, exception);
        }
    }

    /// <inheritdoc />
    public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
        OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);

    /// <inheritdoc />
    public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
        OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);

    /// <inheritdoc />
    public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
    {
        try
        {
            await client.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception exception) when (IsPathFailure(exception))
        {
            throw Translate(path, exception);
        }
    }

    /// <inheritdoc />
    public async Task DeleteAsync(string path, CancellationToken cancellationToken)
    {
        try
        {
            // One call for both kinds: SSH.NET stats the path and issues rmdir or remove accordingly. A
            // non-empty directory fails here, which is the refusal this interface promises.
            await client.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception exception) when (IsPathFailure(exception))
        {
            throw Translate(path, exception);
        }
    }

    /// <inheritdoc />
    public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
    {
        try
        {
            await client.RenameFileAsync(fromPath, toPath, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception exception) when (IsPathFailure(exception))
        {
            // Named for the destination, which is what the caller chose and what a collision is about. The
            // source is a path the transfer queue made up.
            throw Translate(toPath, exception);
        }
    }

    /// <inheritdoc />
    public ValueTask DisposeAsync()
    {
        client.Dispose();
        return ValueTask.CompletedTask;
    }

    /// <remarks>
    /// Seeking after the open rather than asking for an appending mode. <c>FileMode.Append</c> would give the
    /// right position for an upload and nothing for a download, and a resume has to be able to start at an
    /// offset that is <em>not</em> the end — a part file whose tail was written by an interrupted transfer is
    /// exactly that case, and the queue truncates to a known-good length before resuming.
    /// </remarks>
    private async Task<Stream> OpenAsync(
        string path,
        FileMode mode,
        FileAccess access,
        long offset,
        CancellationToken cancellationToken)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(offset);

        SftpFileStream stream;

        try
        {
            stream = await client.OpenAsync(path, mode, access, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception exception) when (IsPathFailure(exception))
        {
            throw Translate(path, exception);
        }

        if (offset == 0)
        {
            return stream;
        }

        try
        {
            stream.Seek(offset, SeekOrigin.Begin);
        }
        catch
        {
            await stream.DisposeAsync().ConfigureAwait(false);
            throw;
        }

        return stream;
    }

    /// <summary>Directories first, then links, then everything else.</summary>
    private static int Rank(SftpEntryKind kind) => kind switch
    {
        SftpEntryKind.Directory => 0,
        SftpEntryKind.SymbolicLink => 1,
        _ => 2,
    };

    private static SftpEntry Describe(ISftpFile file)
    {
        // Checked in this order because the predicates are not exclusive: a symbolic link to a directory
        // answers true to both, and calling that a directory would hide the fact that opening it depends on
        // the server resolving a link. Asking about the link first is the honest reading of an lstat.
        var kind = file switch
        {
            { IsSymbolicLink: true } => SftpEntryKind.SymbolicLink,
            { IsDirectory: true } => SftpEntryKind.Directory,
            { IsRegularFile: true } => SftpEntryKind.File,
            _ => SftpEntryKind.Other,
        };

        return new SftpEntry(
            file.Name,
            file.FullName,
            kind,

            // Only a file's length means anything. A directory's is the size of its own inode, which is a
            // number no user has ever wanted in a SIZE column.
            kind is SftpEntryKind.File ? file.Length : 0,
            new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero),
            PosixMode.Format(
                kind,
                file.OwnerCanRead,
                file.OwnerCanWrite,
                file.OwnerCanExecute,
                file.GroupCanRead,
                file.GroupCanWrite,
                file.GroupCanExecute,
                file.OthersCanRead,
                file.OthersCanWrite,
                file.OthersCanExecute));
    }

    /// <remarks>
    /// <see cref="SshException"/> covers the SFTP-specific types as well, since both derive from it. It is
    /// deliberately wide: the SFTP protocol returns a status code and a server-supplied message for
    /// everything from a missing file to a full disk, and SSH.NET surfaces most of them as a bare
    /// <see cref="SshException"/> carrying that message. What must <em>not</em> be caught is cancellation and
    /// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
    /// </remarks>
    private static bool IsPathFailure(Exception exception) =>
        exception is SshException or IOException or UnauthorizedAccessException;

    private static SftpPathException Translate(string path, Exception exception) => exception switch
    {
        SftpPathNotFoundException => new SftpPathException(path, $"{path} is not there.", exception),

        SftpPermissionDeniedException => new SftpPathException(
            path, $"The server refused access to {path}.", exception),

        // The server's own words. They are the only description of a full disk, a quota or a read-only mount
        // that this client could produce, and inventing a friendlier sentence would lose them.
        _ => new SftpPathException(path, $"{path}: {exception.Message}", exception),
    };
}
