using System.Globalization;

namespace DodoSSH.Client.Ssh;

/// <summary>What kind of thing a remote directory entry is.</summary>
/// <remarks>
/// Taken from the attributes a listing already carries, which are <c>lstat</c> attributes: a symbolic link
/// reports as a link whatever it points at. Resolving each one would be a round trip per entry, so
/// <see cref="SftpEntryKind.SymbolicLink"/> stays its own answer and the caller finds out what it leads to by
/// trying to list it — see <see cref="ISftpSession.ListAsync"/>.
/// </remarks>
public enum SftpEntryKind
{
    /// <summary>An ordinary file.</summary>
    File = 0,

    /// <summary>A directory.</summary>
    Directory = 1,

    /// <summary>A symbolic link, to something this listing did not resolve.</summary>
    SymbolicLink = 2,

    /// <summary>A socket, device, pipe or anything else that is not one of the three above.</summary>
    Other = 3,
}

/// <summary>One entry in a remote directory.</summary>
/// <param name="Name">The entry's own name, with no path.</param>
/// <param name="FullPath">The absolute path, which is what every operation takes.</param>
/// <param name="Kind">What it is.</param>
/// <param name="Length">Size in bytes. Meaningless for anything that is not a file, and zero there.</param>
/// <param name="LastWriteTimeUtc">When it was last written.</param>
/// <param name="Permissions">The mode as <c>drwxr-xr-x</c>; see <see cref="PosixMode"/>.</param>
/// <remarks>
/// A record of what the server said rather than a handle. Nothing here holds a channel open, so a listing can
/// be kept on a screen after the session behind it has gone — which is what the transfers screen does while a
/// connection is being re-established.
/// </remarks>
public sealed record SftpEntry(
    string Name,
    string FullPath,
    SftpEntryKind Kind,
    long Length,
    DateTimeOffset LastWriteTimeUtc,
    string Permissions)
{
    /// <summary>Whether this is somewhere the file browser can navigate into.</summary>
    /// <remarks>
    /// True for a symbolic link as well as a directory, because a link to a directory is the ordinary way a
    /// remote filesystem is laid out and refusing to open one would make those paths unreachable. A link to a
    /// file fails the listing instead, which is the caller's cue that it was not a directory after all.
    /// </remarks>
    public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;

    /// <summary>Whether this is a file somebody can run.</summary>
    /// <remarks>
    /// Files only. On a directory the execute bit means "may be searched", which is true of very nearly every
    /// directory on a host — a listing that marked them all would be marking nothing.
    /// </remarks>
    public bool IsExecutable => Kind is SftpEntryKind.File && PosixMode.HasAnyExecuteBit(Permissions);

    /// <summary>
    /// Whether this is a file any account on the host may write to.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Files only, and for two separate reasons. A symbolic link is <c>lrwxrwxrwx</c> by convention on every
    /// system that has one, and its mode governs nothing: what may be written is the target, whose own mode
    /// this listing did not fetch. And a directory that everyone may write to is the ordinary arrangement for
    /// <c>/tmp</c>, made safe by the sticky bit — which <see cref="PosixMode"/> does not render, so flagging
    /// the directory would be warning about the half of the mode that is on screen while the half that
    /// answers the warning is not.
    /// </para>
    /// <para>
    /// It is not a claim that writing is dangerous, only that the mode says something a reader of that column
    /// would want to have noticed.
    /// </para>
    /// </remarks>
    public bool IsWorldWritable => Kind is SftpEntryKind.File && PosixMode.IsWorldWritable(Permissions);
}

/// <summary>
/// Renders POSIX permission bits the way <c>ls -l</c> does.
/// </summary>
/// <remarks>
/// Nothing in this repository formatted a mode before, and the design's file listing has a <c>PERMS</c>
/// column. It is written from the individual bits rather than from an octal mode because that is the shape
/// SFTP hands over — <c>ISftpFile</c> exposes nine booleans and a set of kind predicates, and reassembling
/// them into an octal number only to take it apart again would be a round trip through a representation
/// neither end uses.
/// <para>
/// The setuid, setgid and sticky bits are not shown. SFTP's own file attributes carry them, SSH.NET does not
/// surface them on <c>ISftpFile</c>, and a column that showed <c>rwx</c> where <c>rws</c> was true would be
/// worse than one that never claims to render them.
/// </para>
/// </remarks>
public static class PosixMode
{
    /// <summary>Formats one entry's mode, kind character included.</summary>
    public static string Format(
        SftpEntryKind kind,
        bool ownerRead,
        bool ownerWrite,
        bool ownerExecute,
        bool groupRead,
        bool groupWrite,
        bool groupExecute,
        bool othersRead,
        bool othersWrite,
        bool othersExecute)
    {
        Span<char> mode = stackalloc char[10];

        mode[0] = kind switch
        {
            SftpEntryKind.Directory => 'd',
            SftpEntryKind.SymbolicLink => 'l',
            SftpEntryKind.File => '-',
            _ => '?',
        };

        Write(mode[1..4], ownerRead, ownerWrite, ownerExecute);
        Write(mode[4..7], groupRead, groupWrite, groupExecute);
        Write(mode[7..10], othersRead, othersWrite, othersExecute);

        return new string(mode);

        static void Write(Span<char> triple, bool read, bool write, bool execute)
        {
            triple[0] = read ? 'r' : '-';
            triple[1] = write ? 'w' : '-';
            triple[2] = execute ? 'x' : '-';
        }
    }

    /// <summary>Whether any of the three execute bits is set.</summary>
    public static bool HasAnyExecuteBit(string mode) =>
        At(mode, OwnerExecute) == 'x' || At(mode, GroupExecute) == 'x' || At(mode, OthersExecute) == 'x';

    /// <summary>Whether the others triple carries the write bit.</summary>
    public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';

    private const int OwnerExecute = 3;
    private const int GroupExecute = 6;
    private const int OthersWrite = 8;
    private const int OthersExecute = 9;

    /// <remarks>
    /// Reading back what <see cref="Format"/> wrote, rather than carrying the nine booleans through
    /// <see cref="SftpEntry"/> as well. The alternative is a second representation of one fact, and the two
    /// disagreeing is the failure this avoids — a row coloured for a bit the column beside it does not show.
    /// Anything that is not a mode this type wrote answers false rather than throwing: these questions decide
    /// a colour, and a listing is not worth failing over one.
    /// </remarks>
    private static char At(string mode, int index) => mode.Length == 10 ? mode[index] : '-';
}

/// <summary>
/// Remote paths, which are POSIX paths whatever this client is running on.
/// </summary>
/// <remarks>
/// <b>Not <see cref="Path"/>.</b> The BCL's path helpers use the local platform's separator, so on Windows
/// <c>Path.Combine("/var", "log")</c> yields <c>/var\log</c> — a path the remote will not resolve and which
/// fails as "no such file" somewhere the user cannot see the backslash. Every remote path in this codebase
/// goes through here.
/// </remarks>
public static class SftpPath
{
    /// <summary>The root of a remote filesystem.</summary>
    public const string Root = "/";

    /// <summary>Joins a directory and a name.</summary>
    public static string Combine(string directory, string name)
    {
        ArgumentException.ThrowIfNullOrEmpty(directory);

        return directory.EndsWith('/') ? directory + name : directory + "/" + name;
    }

    /// <summary>The directory holding a path, or the path itself when it is already the root.</summary>
    public static string Parent(string path)
    {
        ArgumentException.ThrowIfNullOrEmpty(path);

        var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
        var slash = trimmed.LastIndexOf('/');

        return slash switch
        {
            < 0 => Root,
            0 => Root,
            _ => trimmed[..slash],
        };
    }

    /// <summary>The last segment of a path, or the root when that is all there is.</summary>
    /// <remarks>
    /// The root names itself. Taking the text after its only separator leaves an empty string, which as a
    /// heading or a tab label is a blank where a path should be.
    /// </remarks>
    public static string Name(string path)
    {
        ArgumentException.ThrowIfNullOrEmpty(path);

        var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
        var slash = trimmed.LastIndexOf('/');
        var name = slash < 0 ? trimmed : trimmed[(slash + 1)..];

        return name.Length == 0 ? Root : name;
    }

    /// <summary>Whether a path is already anchored at the root.</summary>
    public static bool IsAbsolute(string path) => path.StartsWith('/');

    /// <summary>
    /// The segments of a path, for a breadcrumb trail.
    /// </summary>
    /// <returns>
    /// Each segment paired with the absolute path that reaches it, root first. An empty list for the root
    /// itself, which has no segment to name.
    /// </returns>
    public static IReadOnlyList<(string Name, string Path)> Trail(string path)
    {
        ArgumentException.ThrowIfNullOrEmpty(path);

        var trail = new List<(string, string)>();
        var walked = string.Empty;

        foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
        {
            walked = Combine(walked.Length == 0 ? Root : walked, segment);
            trail.Add((segment, walked));
        }

        return trail;
    }
}

/// <summary>
/// A file-transfer session on one host.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a connection, not a channel.</b> The obvious shape would have been
/// <c>ISshConnection.OpenSftpAsync</c>, opening the SFTP subsystem beside the shell on the transport that is
/// already up — which is what SSH itself allows and what <c>docs/design-import-gaps.md</c> assumed it would
/// take. SSH.NET does not offer it: <c>SftpClient</c> derives from <c>BaseClient</c> and owns its own
/// transport, and there is no supported way to hand it an existing <c>SshClient</c>'s session. So opening one
/// of these authenticates again.
/// </para>
/// <para>
/// It is named for that rather than dressed up as a channel, because the difference is visible to a user: the
/// host sees a second login, a one-time password would be asked for twice, and closing every terminal on a
/// host does not close its file browser. <see cref="ISshConnectionFactory.OpenSftpAsync"/> is a
/// <em>connect</em>, and host key trust is checked on it exactly as it is for a shell.
/// </para>
/// <para>
/// One session is one channel, and everything on it shares that channel's window — so the queue that drives
/// this runs one transfer at a time. That is a throughput decision rather than a safety one: two large
/// transfers over one channel do not go faster than one, they arrive later and both at once. Browsing while a
/// transfer runs is fine, and is the point of not opening a session per transfer.
/// </para>
/// </remarks>
public interface ISftpSession : IRemoteFileStore
{
    /// <summary>The host key that was accepted for this session.</summary>
    HostKeyPresentation HostKey { get; }

    /// <summary>
    /// The negotiated server-to-client encryption algorithm, e.g. <c>aes256-gcm@openssh.com</c>.
    /// </summary>
    /// <remarks>
    /// The same fact <see cref="ISshConnection.Cipher"/> is, read the same way — off SSH.NET's
    /// <c>ConnectionInfo.CurrentServerEncryption</c> once the handshake this session's own connect performed
    /// has finished — and for the same reason: <c>SftpClient</c> derives from <c>BaseClient</c> exactly as
    /// <c>SshClient</c> does, and rekeys are no more visible here than they are there. See that member's remark.
    /// </remarks>
    string Cipher { get; }
}

/// <summary>
/// A remote place with files in it, whatever protocol reaches it.
/// </summary>
/// <remarks>
/// <para>
/// Extracted from <see cref="ISftpSession"/> when buckets arrived, and unchanged in shape — the transfer
/// queue reads, writes, stats and lists, and never once needed anything SSH-specific. What stayed behind on
/// <c>ISftpSession</c> is the one member that could not be answered by a bucket: a host key.
/// </para>
/// <para>
/// <b>It lives in a project called <c>.Ssh</c>, which is a naming debt worth writing down rather than
/// paying.</b> <see cref="SftpEntry"/> is here too and is the type every listing is made of, so moving the
/// interface without moving that would split the vocabulary in half — and moving both means renaming a
/// record that the whole file browser and its tests are written against. The cost of leaving it is a
/// reference that reads oddly from the object-store project; the cost of moving it is a rename with no
/// behaviour in it.
/// </para>
/// <para>
/// <b>Not every implementation can do everything, and the contract says which.</b> An object store has no
/// directories, no rename and no way to resume a half-finished upload; each of those is documented on the
/// member and refused with a reason rather than silently approximated. See <c>S3FileStore</c>.
/// </para>
/// </remarks>
public interface IRemoteFileStore : IAsyncDisposable
{
    /// <summary>Whether the transport is still up.</summary>
    bool IsConnected { get; }

    /// <summary>
    /// Where the session starts, which is the account's home directory.
    /// </summary>
    /// <remarks>
    /// Absolute, because the server resolves it during the handshake. It is the only path this layer knows
    /// without asking, and it is what a file browser should open on.
    /// </remarks>
    string HomeDirectory { get; }

    /// <summary>
    /// Lists a directory.
    /// </summary>
    /// <remarks>
    /// <c>.</c> and <c>..</c> are dropped: every remote directory has them, no user is choosing between them,
    /// and the way up is a breadcrumb rather than a row. Ordered directories first and then by name, which is
    /// what a file browser has to show and what saves every caller sorting it again.
    /// </remarks>
    /// <exception cref="SftpPathException">The path is not a directory, or is not readable.</exception>
    Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken);

    /// <summary>What one path is, or null when nothing is there.</summary>
    Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken);

    /// <summary>
    /// Opens a remote file for reading, starting at an offset.
    /// </summary>
    /// <param name="path">The file.</param>
    /// <param name="offset">Where to start, which is what makes an interrupted download resumable.</param>
    /// <param name="cancellationToken">Abandons the open.</param>
    Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken);

    /// <summary>
    /// Opens a remote file for writing, starting at an offset.
    /// </summary>
    /// <remarks>
    /// Creates the file when it is not there, and does not truncate one that is — an offset of zero over an
    /// existing file overwrites from the beginning and leaves any tail beyond what is written. Callers are
    /// expected to write to a path nothing else holds; the transfer queue writes to a part file for exactly
    /// this reason.
    /// </remarks>
    /// <param name="path">The file.</param>
    /// <param name="offset">Where to start, which is what makes an interrupted upload resumable.</param>
    /// <param name="cancellationToken">Abandons the open.</param>
    Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);

    /// <summary>Creates one directory, whose parent must exist.</summary>
    Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);

    /// <summary>
    /// Deletes a file, or an empty directory.
    /// </summary>
    /// <remarks>
    /// Deliberately not recursive. A recursive remote delete is the one operation on this screen that can
    /// destroy something no undo reaches, and offering it behind the same button as deleting one file is how
    /// that happens by accident. A non-empty directory fails, and says so.
    /// </remarks>
    Task DeleteAsync(string path, CancellationToken cancellationToken);

    /// <summary>Renames or moves a path within the same host.</summary>
    Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
}

/// <summary>
/// Something on the remote filesystem could not be reached, and the server said why.
/// </summary>
/// <remarks>
/// One exception for the whole surface rather than one per operation, because there is exactly one thing a
/// caller does with any of them: show the message beside the path it was about. SSH.NET raises several
/// unrelated types for what a user experiences as one condition — <c>SftpPathNotFoundException</c>,
/// <c>SftpPermissionDeniedException</c>, and a bare <c>SshException</c> for the rest — and the path is not on
/// all of them.
/// </remarks>
public sealed class SftpPathException(string path, string message, Exception? innerException = null)
    : Exception(message, innerException)
{
    /// <summary>The path the failure was about.</summary>
    public string Path { get; } = path;
}

/// <summary>Opens file-transfer sessions.</summary>
/// <remarks>
/// Declared beside <see cref="ISshConnectionFactory"/> and implemented by the same type, because both start
/// with the same handshake and the same host key decision. See <see cref="ISftpSession"/> for why this is a
/// separate connect rather than a channel on a connection that already exists.
/// </remarks>
public interface ISftpSessionFactory
{
    /// <summary>
    /// Connects, authenticates, and starts the SFTP subsystem.
    /// </summary>
    /// <exception cref="SshHostKeyUnknownException">
    /// The host has no pinned key. Resolved exactly as it is for a shell: show the fingerprint, record it on
    /// explicit confirmation, and retry.
    /// </exception>
    /// <exception cref="SshHostKeyMismatchException">
    /// The presented key differs from the pin. There is no retry path.
    /// </exception>
    Task<ISftpSession> OpenSftpAsync(SshConnectionRequest request, CancellationToken cancellationToken);
}

/// <summary>Formats a byte count the way a file browser shows one.</summary>
/// <remarks>
/// Here rather than in the view model because the transfer queue's own progress reporting needs the same
/// wording, and two spellings of "1.4 MB" in one window reads as two different measurements.
/// </remarks>
public static class ByteSize
{
    private static readonly string[] Units = ["B", "KB", "MB", "GB", "TB"];

    /// <summary>Formats a byte count to three significant figures.</summary>
    public static string Format(long bytes)
    {
        if (bytes < 1024)
        {
            return string.Create(CultureInfo.InvariantCulture, $"{bytes} B");
        }

        double scaled = bytes;
        var unit = 0;

        while (scaled >= 1024 && unit < Units.Length - 1)
        {
            scaled /= 1024;
            unit++;
        }

        // One decimal below ten, none above: "9.4 MB" and "512 MB" are both three characters of information,
        // and "512.3 MB" is a precision the number does not have by the time it is that large.
        return scaled < 10
            ? string.Create(CultureInfo.InvariantCulture, $"{scaled:0.0} {Units[unit]}")
            : string.Create(CultureInfo.InvariantCulture, $"{scaled:0} {Units[unit]}");
    }
}
