namespace DodoSSH.Client.Transfer;

/// <summary>One entry in a local directory.</summary>
/// <param name="Name">The entry's own name.</param>
/// <param name="FullPath">The absolute path.</param>
/// <param name="IsDirectory">Whether it can be navigated into.</param>
/// <param name="Length">Size in bytes, or zero for a directory.</param>
/// <param name="LastWriteTimeUtc">When it was last written.</param>
/// <remarks>
/// Deliberately the same shape as <c>SftpEntry</c> minus the permission string, because the two panes of the
/// file browser show the same columns and a local mode rendered in POSIX notation would be a fiction on
/// Windows — where the design's <c>PERMS</c> column has no honest value at all.
/// </remarks>
public sealed record LocalEntry(
    string Name,
    string FullPath,
    bool IsDirectory,
    long Length,
    DateTimeOffset LastWriteTimeUtc);

/// <summary>
/// The local half of the file browser.
/// </summary>
/// <remarks>
/// <para>
/// The first <c>System.IO</c> in the client outside the cache and the device key, and it stays behind this
/// one type on purpose: everything above reasons about <see cref="LocalEntry"/>, so the transfers screen and
/// its view model never enumerate a directory themselves.
/// </para>
/// <para>
/// <b>Nothing here throws for one unreadable entry.</b> A local directory listing on Windows routinely
/// contains things the user cannot stat — a junction into another profile, a file another process holds
/// open — and a browser that failed the whole listing for one of them would be unable to show
/// <c>C:\Users</c>. Those entries are skipped; a directory that cannot be opened at all is still a failure,
/// because there is nothing to show.
/// </para>
/// </remarks>
public static class LocalDirectory
{
    /// <summary>Where the local pane opens.</summary>
    /// <remarks>
    /// The user profile rather than the process's working directory, which for a desktop application is
    /// wherever it happened to be launched from — an installation directory nobody keeps files in.
    /// </remarks>
    public static string Home =>
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None);

    /// <summary>
    /// Lists a directory, directories first and then by name.
    /// </summary>
    /// <exception cref="IOException">The directory could not be opened.</exception>
    /// <exception cref="UnauthorizedAccessException">The directory could not be opened.</exception>
    public static IReadOnlyList<LocalEntry> List(string path)
    {
        var directory = new DirectoryInfo(path);
        var entries = new List<LocalEntry>();

        // EnumerateFileSystemInfos rather than GetFileSystemInfos: the enumerating form yields entries as it
        // reads them, so a directory of fifty thousand files does not have to be materialised twice.
        foreach (var entry in directory.EnumerateFileSystemInfos())
        {
            try
            {
                var isDirectory = entry.Attributes.HasFlag(FileAttributes.Directory);

                entries.Add(new LocalEntry(
                    entry.Name,
                    entry.FullName,
                    isDirectory,
                    isDirectory ? 0 : ((FileInfo)entry).Length,
                    new DateTimeOffset(entry.LastWriteTimeUtc, TimeSpan.Zero)));
            }
            catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
            {
                // One entry this process cannot stat. Skipped rather than shown as a row with no facts on
                // it, and skipped rather than failing the listing — see the remark on this type.
            }
        }

        entries.Sort(static (left, right) => left.IsDirectory == right.IsDirectory
            ? string.Compare(left.Name, right.Name, StringComparison.CurrentCultureIgnoreCase)
            : right.IsDirectory.CompareTo(left.IsDirectory));

        return entries;
    }

    /// <summary>
    /// The directory holding a path, or null when it is already a root.
    /// </summary>
    /// <remarks>
    /// Null rather than the path itself, because the local pane's "up" has somewhere further to go than the
    /// remote's does: above <c>C:\</c> is the list of drives, which is not a directory. The remote pane stops
    /// at <c>/</c>, which is.
    /// </remarks>
    public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));

    /// <summary>
    /// Where the local pane can start from: the drives on Windows, and the root elsewhere.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Ready drives only, on Windows. An empty optical drive or a disconnected network mapping is listed by
    /// <see cref="DriveInfo.GetDrives"/> and throws on the first attempt to read it, which would put a row on
    /// screen whose only behaviour is an error.
    /// </para>
    /// <para>
    /// Unix does not go through <see cref="DriveInfo.GetDrives"/> at all, and the summary above was true of
    /// the intent long before it was true of the code. That call returns every mount the kernel holds, which
    /// on an ordinary laptop is upwards of forty: <c>/proc</c>, <c>/sys/fs/bpf</c>, one per installed snap,
    /// <c>/run/user/1000/doc</c>. The transfers screen draws a button per root, so the bar ran to some five
    /// thousand pixels inside an eight-hundred pixel window — found by the layout suite the first time it
    /// ran on Linux, which is the whole reason that suite measures rather than eyeballs.
    /// </para>
    /// <para>
    /// Filtering that list is not workable, and it is worth writing down why so nobody tries it again.
    /// Neither property that looks like it would separate a real volume from the kernel's bookkeeping does:
    /// <see cref="DriveInfo.DriveType"/> answers <c>Fixed</c> for <c>/</c> and <c>/home</c>, but equally for
    /// every squashfs snap, for <c>efivarfs</c> and for <c>tracefs</c>, while <c>/boot/efi</c> comes back
    /// <c>Removable</c>. <see cref="DriveInfo.DriveFormat"/> would need a hand-maintained list of every
    /// virtual filesystem Linux might grow, which is a list that is wrong the moment it is written.
    /// </para>
    /// <para>
    /// So this names what somebody would actually want rather than subtracting what they would not: the
    /// root, their home, and whatever is mounted where removable media conventionally mounts. A volume
    /// anywhere else is still reachable by navigating from <c>/</c>, which is what the pane is for.
    /// </para>
    /// </remarks>
    public static IReadOnlyList<string> Roots()
    {
        var roots = new List<string>();

        if (OperatingSystem.IsWindows())
        {
            foreach (var drive in DriveInfo.GetDrives())
            {
                try
                {
                    if (drive.IsReady)
                    {
                        roots.Add(drive.RootDirectory.FullName);
                    }
                }
                catch (IOException)
                {
                    // A drive that fails even to answer whether it is ready. Nothing to show.
                }
            }

            return roots;
        }

        roots.Add("/");

        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        if (!string.IsNullOrEmpty(home) && Directory.Exists(home))
        {
            roots.Add(home);
        }

        // Where a desktop mounts a stick. /run/media/<user> is udisks2's, and so the one that matters on a
        // current distribution; /media and /mnt stay because a hand-written fstab still uses them, and
        // /Volumes is macOS, which reaches this branch too.
        string[] mountParents =
        [
            "/run/media/" + Environment.UserName,
            "/media",
            "/mnt",
            "/Volumes",
        ];

        foreach (var parent in mountParents)
        {
            try
            {
                if (Directory.Exists(parent))
                {
                    roots.AddRange(Directory.EnumerateDirectories(parent));
                }
            }
            catch (Exception e) when (e is IOException or UnauthorizedAccessException)
            {
                // A media directory that will not be listed contributes nothing. Failing to offer a
                // shortcut to a stick is not a reason to have no roots bar at all.
            }
        }

        return roots;
    }
}
