using System.Globalization;

namespace DodoSSH.Client.Import;

/// <summary>
/// Reads an OpenSSH client configuration into blocks and directives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pure, and takes its include reader as a parameter.</b> That is what makes <c>Include</c> — the one
/// directive whose behaviour depends on the file system — testable without a file system, and it keeps the
/// recursion depth cap and the cycle detection here, next to the recursion, rather than in whatever happens
/// to be doing the reading.
/// </para>
/// <para>
/// <b>Deliberately not a complete implementation of ssh_config, and the gaps are reported rather than
/// hidden.</b> <c>Match</c> blocks are not evaluated: <c>Match exec</c> runs a command, <c>Match host</c>
/// depends on what is being connected to, and <c>Match final</c> depends on the result of everything else —
/// none of which is knowable while looking at a file. Token expansion beyond <c>~</c>,
/// <c>CanonicalizeHostname</c> and negated patterns are all out of scope for the same reason: this is an
/// importer producing bookmarks somebody will check, not a second SSH client.
/// </para>
/// </remarks>
public static class SshConfigParser
{
    /// <summary>How deep <c>Include</c> may nest before this gives up.</summary>
    /// <remarks>
    /// OpenSSH's own limit is 16. Matching it means a config this refuses is one <c>ssh</c> refuses too,
    /// which is a better answer than a different arbitrary number.
    /// </remarks>
    private const int MaximumIncludeDepth = 16;

    /// <summary>
    /// Parses configuration text.
    /// </summary>
    /// <param name="text">The file's contents.</param>
    /// <param name="includeReader">
    /// Resolves an <c>Include</c> pattern to the contents of every file it names, in order. Return an empty
    /// sequence for a pattern that matches nothing, which is what OpenSSH does — an <c>Include</c> naming no
    /// file is not an error.
    /// </param>
    public static SshConfigDocument Parse(string text, Func<string, IReadOnlyList<string>>? includeReader = null)
    {
        ArgumentNullException.ThrowIfNull(text);

        var blocks = new List<SshConfigBlock>();
        var warnings = new List<string>();
        var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        ParseInto(text, includeReader, blocks, warnings, visited, depth: 0);

        return new SshConfigDocument(blocks, warnings);
    }

    private static void ParseInto(
        string text,
        Func<string, IReadOnlyList<string>>? includeReader,
        List<SshConfigBlock> blocks,
        List<string> warnings,
        HashSet<string> visited,
        int depth)
    {
        List<string>? patterns = null;
        var directives = new List<SshConfigDirective>();

        // A Match block is "everything until the next Host or Match", and while one is open its directives
        // are dropped rather than attributed to whatever block came before — which is what a naive parser
        // does, and it silently gives one host another host's settings.
        var insideMatch = false;
        var matchBlocks = 0;

        foreach (var raw in text.Split('\n'))
        {
            var (keyword, value) = Tokenise(raw);

            if (keyword is null)
            {
                continue;
            }

            if (Is(keyword, "Host"))
            {
                Flush(blocks, patterns, directives);

                patterns = SplitPatterns(value);
                directives = [];
                insideMatch = false;
            }
            else if (Is(keyword, "Match"))
            {
                Flush(blocks, patterns, directives);

                patterns = null;
                directives = [];
                insideMatch = true;
                matchBlocks++;
            }
            else if (insideMatch)
            {
                continue;
            }
            else if (Is(keyword, "Include"))
            {
                // Flushed first, so the included file's blocks land between this block and the next — which
                // is where OpenSSH puts them, and it matters because the first value seen for a keyword is
                // the one that wins.
                Flush(blocks, patterns, directives);
                patterns = null;
                directives = [];

                Include(value, includeReader, blocks, warnings, visited, depth);
            }
            else
            {
                directives.Add(new SshConfigDirective(keyword, value));
            }
        }

        Flush(blocks, patterns, directives);

        WarnAboutMatchBlocks(matchBlocks, warnings);
    }

    /// <remarks>
    /// Counted rather than listed. What a reader needs is that some of their file was not honoured and why;
    /// naming each <c>Match</c> condition would be repeating the file back at them.
    /// </remarks>
    private static void WarnAboutMatchBlocks(int matchBlocks, List<string> warnings)
    {
        if (matchBlocks == 0)
        {
            return;
        }

        warnings.Add(string.Create(
            CultureInfo.CurrentCulture,
            $"{matchBlocks} Match block(s) were ignored. Whether one applies depends on what is being connected to, or on a command's output, so it cannot be decided from the file alone."));
    }

    private static bool Is(string keyword, string name) =>
        string.Equals(keyword, name, StringComparison.OrdinalIgnoreCase);

    private static void Include(
        string pattern,
        Func<string, IReadOnlyList<string>>? includeReader,
        List<SshConfigBlock> blocks,
        List<string> warnings,
        HashSet<string> visited,
        int depth)
    {
        if (includeReader is null)
        {
            warnings.Add($"Include {pattern} was skipped: nothing was supplied to read included files.");
            return;
        }

        if (depth >= MaximumIncludeDepth)
        {
            warnings.Add($"Include {pattern} was skipped: includes are nested more than {MaximumIncludeDepth} deep.");
            return;
        }

        // Cycles are the reason this is a set rather than a counter. A file that includes itself — directly
        // or through a chain — would otherwise recurse until the depth cap, importing the same hosts sixteen
        // times before stopping, which reads as a bug in the importer rather than in the config.
        if (!visited.Add(pattern))
        {
            warnings.Add($"Include {pattern} was skipped: it is already being read further up.");
            return;
        }

        try
        {
            foreach (var included in includeReader(pattern))
            {
                ParseInto(included, includeReader, blocks, warnings, visited, depth + 1);
            }
        }
        finally
        {
            visited.Remove(pattern);
        }
    }

    private static void Flush(
        List<SshConfigBlock> blocks,
        List<string>? patterns,
        List<SshConfigDirective> directives)
    {
        if (patterns is { Count: > 0 })
        {
            blocks.Add(new SshConfigBlock(patterns, directives));
        }
    }

    /// <summary>
    /// Splits one line into a keyword and a value, or nothing.
    /// </summary>
    /// <remarks>
    /// OpenSSH accepts <c>Keyword Value</c>, <c>Keyword=Value</c> and <c>Keyword = Value</c>, allows leading
    /// whitespace, treats <c>#</c> as a comment, and lets a value be double-quoted. The quoting is what this
    /// has to get right rather than approximately right: <c>IdentityFile "~/my keys/id_ed25519"</c> is one
    /// path, and splitting it on whitespace produces two that do not exist.
    /// </remarks>
    private static (string? Keyword, string Value) Tokenise(string line)
    {
        // A BOM on the first line, and CR on every line of a CRLF file. Both are invisible and both would
        // otherwise end up inside the first keyword, where nothing matches them.
        var trimmed = line.Trim('﻿', '\r').Trim();

        if (trimmed.Length == 0 || trimmed[0] == '#')
        {
            return (null, string.Empty);
        }

        var separator = trimmed.AsSpan().IndexOfAny(" \t=");

        if (separator < 0)
        {
            return (trimmed, string.Empty);
        }

        var keyword = trimmed[..separator];
        var rest = trimmed[separator..].TrimStart(' ', '\t');

        if (rest.StartsWith('='))
        {
            rest = rest[1..].TrimStart(' ', '\t');
        }

        return (keyword, Unquote(rest));
    }

    private static string Unquote(string value)
    {
        var trimmed = value.Trim();

        return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
            ? trimmed[1..^1]
            : trimmed;
    }

    /// <remarks>
    /// Each token unquoted separately, because <c>Host "my server" other</c> is two patterns and one of them
    /// contains a space.
    /// </remarks>
    private static List<string> SplitPatterns(string value)
    {
        var patterns = new List<string>();
        var span = value.AsSpan();
        var index = 0;

        while (index < span.Length)
        {
            while (index < span.Length && char.IsWhiteSpace(span[index]))
            {
                index++;
            }

            if (index >= span.Length)
            {
                break;
            }

            int end;

            if (span[index] == '"')
            {
                index++;
                end = index;

                while (end < span.Length && span[end] != '"')
                {
                    end++;
                }

                patterns.Add(span[index..end].ToString());
                index = end + 1;
                continue;
            }

            end = index;

            while (end < span.Length && !char.IsWhiteSpace(span[end]))
            {
                end++;
            }

            patterns.Add(span[index..end].ToString());
            index = end;
        }

        return patterns;
    }
}
