namespace DodoSSH.Client.Import;

/// <summary>
/// Finds and reads the user's OpenSSH client configuration.
/// </summary>
/// <remarks>
/// The only type here that touches a disk, which is what keeps <see cref="SshConfigParser"/> and
/// <see cref="SshConfigResolver"/> testable against strings.
/// </remarks>
public sealed class SshConfigLocator
{
    private readonly string sshDirectory;

    /// <param name="sshDirectory">
    /// Where to look. Defaults to <c>~/.ssh</c>, which is the location on Windows as well as everywhere
    /// else — OpenSSH on Windows uses the profile directory, not <c>%APPDATA%</c>.
    /// </param>
    public SshConfigLocator(string? sshDirectory = null) =>
        this.sshDirectory = sshDirectory ?? Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".ssh");

    /// <summary>The file this would read.</summary>
    public string ConfigPath => Path.Combine(sshDirectory, "config");

    /// <summary>Whether there is anything to read.</summary>
    public bool Exists => File.Exists(ConfigPath);

    /// <summary>
    /// Reads the private key an <c>IdentityFile</c> named.
    /// </summary>
    /// <remarks>
    /// Here rather than on <see cref="SshIdentityFiles"/> directly because this type owns the one fact that
    /// call needs and nothing else has: where <c>~/.ssh</c> is for this run. That is what a relative
    /// <c>IdentityFile</c> resolves against, and it is what the tests override to point at a directory they
    /// made — see the class remark on why the disk lives in exactly one type here.
    /// </remarks>
    public SshIdentityFile ReadIdentity(string path) => SshIdentityFiles.Read(path, sshDirectory);

    /// <summary>Reads and resolves the configuration.</summary>
    /// <exception cref="FileNotFoundException">There is no configuration file.</exception>
    public async Task<SshConfigImport> ReadAsync(CancellationToken cancellationToken)
    {
        var text = await File.ReadAllTextAsync(ConfigPath, cancellationToken).ConfigureAwait(false);

        return SshConfigResolver.Resolve(SshConfigParser.Parse(text, ReadIncluded));
    }

    /// <summary>
    /// Reads every file an <c>Include</c> pattern names.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A relative pattern resolves against <c>~/.ssh</c>, which is OpenSSH's rule for the user file. Glob
    /// characters are handled by enumerating the directory rather than by matching by hand — a pattern like
    /// <c>conf.d/*.conf</c> is the common shape and is what the enumeration overload is for.
    /// </para>
    /// <para>
    /// Everything here swallows its own failures and returns nothing. An <c>Include</c> naming a file that
    /// does not exist is not an error to OpenSSH, and an unreadable one is a reason to import less rather
    /// than a reason to import nothing — the parser records the shortfall in its warnings either way.
    /// </para>
    /// </remarks>
    private IReadOnlyList<string> ReadIncluded(string pattern)
    {
        try
        {
            var rooted = Path.IsPathRooted(pattern) ? pattern : Path.Combine(sshDirectory, pattern);
            var directory = Path.GetDirectoryName(rooted);
            var mask = Path.GetFileName(rooted);

            if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(mask) || !Directory.Exists(directory))
            {
                return [];
            }

            return [.. Directory
                .EnumerateFiles(directory, mask, SearchOption.TopDirectoryOnly)
                .Order(StringComparer.Ordinal)
                .Select(File.ReadAllText)];
        }
        catch (IOException)
        {
            return [];
        }
        catch (UnauthorizedAccessException)
        {
            return [];
        }
    }
}
