using System.Buffers;
using System.Globalization;
using DodoSSH.Client.Domain;

namespace DodoSSH.Client.Import;

/// <summary>
/// Turns parsed blocks into the hosts an import can offer.
/// </summary>
/// <remarks>
/// <para>
/// <b>First value wins.</b> That is the actual OpenSSH rule and it is not the intuitive one — a later
/// <c>Host *</c> block supplies defaults for keywords nothing earlier set, and cannot override a keyword an
/// earlier block already set. Getting it backwards produces an import where every host has the wildcard
/// block's username.
/// </para>
/// <para>
/// <b>A block whose patterns are all wildcards contributes defaults and is not itself importable.</b>
/// <c>Host *.internal</c> is a rule about names, not a machine — a bookmark by that name could not be
/// dialled. Those are reported so the preview can say what was used and not imported, rather than leaving
/// somebody to wonder why six blocks produced four hosts.
/// </para>
/// </remarks>
public static class SshConfigResolver
{
    private static readonly SearchValues<char> PatternCharacters = SearchValues.Create("*?!");

    /// <summary>Resolves every importable host in a parsed configuration.</summary>
    public static SshConfigImport Resolve(SshConfigDocument document)
    {
        ArgumentNullException.ThrowIfNull(document);

        var hosts = new List<ImportedHost>();
        var skipped = new List<string>();
        var warnings = new List<string>(document.Warnings);

        foreach (var pattern in document.Blocks.SelectMany(block => block.Patterns).Where(IsPattern))
        {
            if (!skipped.Contains(pattern, StringComparer.Ordinal))
            {
                skipped.Add(pattern);
            }
        }

        foreach (var alias in document.Blocks.SelectMany(block => block.Patterns).Where(name => !IsPattern(name)))
        {
            if (hosts.Any(host => string.Equals(host.Alias, alias, StringComparison.OrdinalIgnoreCase)))
            {
                continue;
            }

            hosts.Add(Resolve(alias, document));
        }

        if (skipped.Count > 0)
        {
            var named = string.Join(", ", skipped);

            warnings.Add(string.Create(
                CultureInfo.CurrentCulture,
                $"{skipped.Count} pattern block(s) — {named} — supplied defaults but were not imported as hosts. A pattern names a rule, not a machine."));
        }

        return new SshConfigImport(hosts, skipped, warnings);
    }

    private static ImportedHost Resolve(string alias, SshConfigDocument document)
    {
        // Case-insensitive, because SSH keywords are and HostOption.NameComparer already says so. Two
        // spellings of ServerAliveInterval reaching HostOptions.Create would be a duplicate-name throw.
        var settled = new Dictionary<string, string>(HostOption.NameComparer);
        var identityFiles = new List<string>();
        var warnings = new List<string>();

        Settle(alias, document, settled, identityFiles, warnings);

        var port = ResolvePort(settled, warnings);
        var hostname = Take(settled, "HostName") ?? alias;
        var username = Take(settled, "User");
        var proxyJump = Take(settled, "ProxyJump");

        if (Take(settled, "ProxyCommand") is { } proxyCommand)
        {
            // Not put into Options: it would look like a setting that does something. Nothing in this
            // application runs a ProxyCommand, and a directive sitting in a host's editor implying otherwise
            // is worse than a sentence saying it was dropped.
            warnings.Add($"ProxyCommand was dropped: nothing here runs one. It was '{proxyCommand}'.");
        }

        return new ImportedHost(
            alias,
            hostname,
            username,
            port,
            identityFiles,
            proxyJump,
            HostOptions.Create(settled.Select(entry => new HostOption(entry.Key, entry.Value))),
            warnings);
    }

    /// <summary>Walks every block that applies to an alias, keeping the first value for each keyword.</summary>
    private static void Settle(
        string alias,
        SshConfigDocument document,
        Dictionary<string, string> settled,
        List<string> identityFiles,
        List<string> warnings)
    {
        var duplicates = new HashSet<string>(HostOption.NameComparer);

        foreach (var block in document.Blocks.Where(block => block.Patterns.Any(pattern => Matches(pattern, alias))))
        {
            foreach (var directive in block.Directives)
            {
                // IdentityFile is the one keyword that legitimately repeats — ssh tries each in turn — so it
                // accumulates instead of settling, and is not reported as a duplicate.
                if (string.Equals(directive.Keyword, "IdentityFile", StringComparison.OrdinalIgnoreCase))
                {
                    identityFiles.Add(ExpandHome(directive.Value));
                    continue;
                }

                if (!settled.TryAdd(directive.Keyword, directive.Value))
                {
                    duplicates.Add(directive.Keyword);
                }
            }
        }

        foreach (var keyword in duplicates.Order(HostOption.NameComparer))
        {
            // HostOptions is unique by name and cannot hold a repeat, which is a stated M1 limitation whose
            // own remarks require the import path to surface it rather than quietly keep one. The first is
            // kept because that is what ssh would have used.
            warnings.Add($"{keyword} was set more than once; the first value was kept.");
        }
    }

    private static int ResolvePort(Dictionary<string, string> settled, List<string> warnings)
    {
        if (Take(settled, "Port") is not { } portText)
        {
            return HostSecret.DefaultPort;
        }

        if (int.TryParse(portText, CultureInfo.InvariantCulture, out var parsed) && parsed is > 0 and <= 65535)
        {
            return parsed;
        }

        warnings.Add($"Port '{portText}' is not a usable port number; 22 was used.");

        return HostSecret.DefaultPort;
    }

    /// <remarks>
    /// Removed as it is read, so a keyword that maps onto a first-class field does not <em>also</em> end up
    /// in <c>Options</c>. A host carrying both a <c>Port</c> of 2222 and a <c>Port</c> directive saying 2222
    /// has two places to change it and one of them will be forgotten.
    /// </remarks>
    private static string? Take(Dictionary<string, string> settled, string keyword)
    {
        if (!settled.Remove(keyword, out var value))
        {
            return null;
        }

        return string.IsNullOrWhiteSpace(value) ? null : value;
    }

    private static bool IsPattern(string name) => name.AsSpan().ContainsAny(PatternCharacters);

    /// <summary>
    /// Whether a <c>Host</c> pattern applies to an alias.
    /// </summary>
    /// <remarks>
    /// <c>*</c> and <c>?</c> only. Negation is not implemented — a <c>!</c> pattern is treated as not
    /// matching, which errs towards importing a host with fewer defaults rather than towards silently
    /// applying a block the user had excluded.
    /// </remarks>
    private static bool Matches(string pattern, string alias)
    {
        if (pattern.StartsWith('!'))
        {
            return false;
        }

        return !pattern.AsSpan().ContainsAny(PatternCharacters)
            ? string.Equals(pattern, alias, StringComparison.OrdinalIgnoreCase)
            : Glob(pattern.AsSpan(), alias.AsSpan());
    }

    private static bool Glob(ReadOnlySpan<char> pattern, ReadOnlySpan<char> value)
    {
        if (pattern.IsEmpty)
        {
            return value.IsEmpty;
        }

        if (pattern[0] == '*')
        {
            for (var skip = 0; skip <= value.Length; skip++)
            {
                if (Glob(pattern[1..], value[skip..]))
                {
                    return true;
                }
            }

            return false;
        }

        if (value.IsEmpty)
        {
            return false;
        }

        return (pattern[0] == '?' || char.ToUpperInvariant(pattern[0]) == char.ToUpperInvariant(value[0]))
            && Glob(pattern[1..], value[1..]);
    }

    /// <remarks>
    /// Tilde only. <c>%h</c>, <c>%p</c> and the rest are left alone: they are expanded per connection
    /// against values this importer does not have, and a path with a literal <c>%h</c> in it is at least
    /// visibly unexpanded rather than wrong.
    /// </remarks>
    private static string ExpandHome(string path)
    {
        if (!path.StartsWith("~/", StringComparison.Ordinal) && !path.StartsWith("~\\", StringComparison.Ordinal))
        {
            return path;
        }

        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);

        return Path.Combine(home, path[2..]);
    }
}
