using System.Runtime.InteropServices;

namespace DodoSSH.Client.Domain;

/// <summary>Which of the three ways a resolved host authenticates.</summary>
public enum ResolvedBindingKind
{
    /// <summary>
    /// A password typed at connect time. The terminal answer, reached either because nothing in the chain
    /// bound anything or because the host said so outright.
    /// </summary>
    TypedPassword = 0,

    /// <summary>A vault SSH key.</summary>
    SshKey = 1,

    /// <summary>A vault credential.</summary>
    Credential = 2,
}

/// <summary>
/// One resolved value, and the group it came from.
/// </summary>
/// <typeparam name="T">The value's type.</typeparam>
/// <param name="Value">What the connect path should use.</param>
/// <param name="FromGroupId">
/// The group that supplied it, or null when the host supplied it itself — which is also what the editor
/// needs, because a value the host owns is text in a box and a value it inherits is a placeholder behind an
/// empty one.
/// </param>
[StructLayout(LayoutKind.Auto)]
public readonly record struct Inherited<T>(T Value, Guid? FromGroupId)
{
    /// <summary>Whether this came from a group rather than from the host.</summary>
    public bool IsInherited => FromGroupId is not null;
}

/// <summary>
/// How a host authenticates once its group chain has been consulted.
/// </summary>
/// <param name="Kind">Which of the three ways.</param>
/// <param name="EntityId">
/// The key or credential item id, or null for a typed password.
/// </param>
/// <param name="FromGroupId">The group that supplied the binding, or null when the host did.</param>
[StructLayout(LayoutKind.Auto)]
public readonly record struct ResolvedBinding(
    ResolvedBindingKind Kind,
    Guid? EntityId,
    Guid? FromGroupId)
{
    /// <summary>A password typed at connect time, decided by the host itself.</summary>
    public static ResolvedBinding TypedByTheHost { get; } =
        new(ResolvedBindingKind.TypedPassword, null, null);

    /// <summary>Whether this came from a group rather than from the host.</summary>
    public bool IsInherited => FromGroupId is not null;
}

/// <summary>
/// Everything about a host that only makes sense once its groups have been read.
/// </summary>
/// <param name="Port">The port to dial.</param>
/// <param name="Username">The user to log in as, or null when nothing supplied one.</param>
/// <param name="Binding">How to authenticate.</param>
public sealed record ResolvedHost(
    Inherited<int> Port,
    Inherited<string?> Username,
    ResolvedBinding Binding);

/// <summary>
/// Resolves a host against the groups above it.
/// </summary>
/// <remarks>
/// <para>
/// A host may leave its port, its username and its binding unset, in which case each is taken from the
/// nearest group above it that states one — its own group, then that group's parent, and so on. Nothing is
/// copied at creation, so changing a group changes every host beneath it that never overrode the field. See
/// <see cref="HostGroupSecret.DefaultPort"/>.
/// </para>
/// <para>
/// <b>Every walk carries a visited set, and that is load-bearing rather than defensive.</b> Two clients can
/// each re-parent A under B and B under A while offline; the merge resolves one item against one item and
/// cannot see the pair, and the server cannot see it either because the pointer is inside the payload. With
/// inheritance the chain is walked on the connect path, so an unguarded cycle is not an undrawable sidebar
/// — it is a shell that never opens. Stopping at the first repeat degrades a cycle to a group that reads as
/// a root: flat headings, defaults unresolved past that point, and clearing the parent in the editor is the
/// repair.
/// </para>
/// <para>
/// <b>The key-or-credential exclusion is enforced here as well as on each record.</b> A host naming a
/// credential under a group naming a key is two individually valid items, so per-record validation cannot
/// catch it. The nearest statement wins outright — host over group, nearer group over further — and this
/// never returns both.
/// </para>
/// <para>
/// Groups are handed in as a lookup rather than fetched, because this runs on the connect path and on every
/// row of a list. The caller already holds the decrypted group list; a resolver that went back to a
/// repository would decrypt the vault once per host drawn.
/// </para>
/// </remarks>
public static class HostInheritance
{
    /// <summary>
    /// Resolves one host.
    /// </summary>
    /// <param name="host">The host, as stored.</param>
    /// <param name="groups">
    /// Every group in the vaults being read, by item id. A missing id is a group deleted on another machine,
    /// and the walk stops there — the host keeps whatever it had already resolved and falls back for the
    /// rest, rather than failing.
    /// </param>
    public static ResolvedHost Resolve(HostSecret host, IReadOnlyDictionary<Guid, HostGroupSecret> groups)
    {
        ArgumentNullException.ThrowIfNull(host);
        ArgumentNullException.ThrowIfNull(groups);

        Inherited<int>? port = host.Port is { } pinned ? new Inherited<int>(pinned, null) : null;

        // Note which null is which. `host.Username is null` means the host stated nothing and the walk
        // continues; an empty string is a statement — "no username" — and stops it, so a host under a group
        // can opt out of the group's user. See HostSecret.Username.
        Inherited<string?>? username = host.Username is not null
            ? new Inherited<string?>(host.Username, null)
            : null;

        var binding = BindingOf(host);

        foreach (var (groupId, group) in Chain(host.GroupId, groups))
        {
            port ??= group.DefaultPort is { } inheritedPort
                ? new Inherited<int>(inheritedPort, groupId)
                : null;

            username ??= group.DefaultUsername is not null
                ? new Inherited<string?>(group.DefaultUsername, groupId)
                : null;

            binding ??= BindingOf(group, groupId);

            if (port is not null && username is not null && binding is not null)
            {
                break;
            }
        }

        return new ResolvedHost(
            port ?? new Inherited<int>(HostSecret.DefaultPort, null),
            username ?? new Inherited<string?>(null, null),
            binding ?? ResolvedBinding.TypedByTheHost);
    }

    /// <summary>
    /// The groups above a host, nearest first, stopping at a repeat.
    /// </summary>
    /// <remarks>
    /// Public because the editor walks the same chain to draw its placeholders and the sidebar walks it to
    /// draw headings, and three separate walks would be three places to forget the visited set. Yields the
    /// id beside the group, because a resolved value has to be able to say which group it came from.
    /// </remarks>
    /// <param name="groupId">Where to start: a host's group, or a group's parent.</param>
    /// <param name="groups">Every group in the vaults being read, by item id.</param>
    public static IEnumerable<(Guid Id, HostGroupSecret Group)> Chain(
        Guid? groupId,
        IReadOnlyDictionary<Guid, HostGroupSecret> groups)
    {
        ArgumentNullException.ThrowIfNull(groups);

        // Allocated per walk rather than shared, because these run concurrently on the connect path and on
        // whatever thread a list is drawn from.
        var visited = new HashSet<Guid>();
        var current = groupId;

        while (current is { } id && visited.Add(id))
        {
            if (!groups.TryGetValue(id, out var group))
            {
                // A dangling id: the group was deleted on another machine. The host falls under the
                // ungrouped heading and inherits nothing further, which is the same answer the interface
                // gives for a group that is simply not there.
                yield break;
            }

            yield return (id, group);

            current = group.ParentId;
        }
    }

    /// <remarks>
    /// Null means "this record did not answer", which is what lets the caller keep walking. It is not the
    /// same as <see cref="ResolvedBindingKind.TypedPassword"/>, which is an answer — and conflating the two
    /// is how a host deliberately put back on a typed password would silently pick up its group's key.
    /// </remarks>
    private static ResolvedBinding? BindingOf(HostSecret host) => host switch
    {
        { SshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, null),
        { CredentialId: { } credential } =>
            new ResolvedBinding(ResolvedBindingKind.Credential, credential, null),
        { AsksForPassword: true } => ResolvedBinding.TypedByTheHost,
        _ => null,
    };

    /// <remarks>
    /// A group has no equivalent of <see cref="HostSecret.AsksForPassword"/>, so it either lends a binding
    /// or says nothing. "Everything under here types its password" is not a thing a group can assert: it
    /// would be indistinguishable from a group that simply has no default, and the two would resolve the
    /// same way anyway once the chain ran out.
    /// </remarks>
    private static ResolvedBinding? BindingOf(HostGroupSecret group, Guid groupId) => group switch
    {
        { DefaultSshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, groupId),
        { DefaultCredentialId: { } credential } =>
            new ResolvedBinding(ResolvedBindingKind.Credential, credential, groupId),
        _ => null,
    };
}
