using System.Globalization;

namespace DodoSSH.Client.Domain;

/// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The group to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record HostGroupMergeResult(
    HostGroupSecret Merged,
    IReadOnlyList<HostFieldConflict> Conflicts)
{
    /// <summary>Whether anything had to be overridden.</summary>
    public bool HasConflicts => Conflicts.Count > 0;
}

/// <summary>
/// Merges two divergent versions of a group against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Six scalars, no collections, which keeps this the simplest item merge in the client — and the
/// interesting thing about it is still what it does <em>not</em> have to consider. Filing a host into a
/// group does not write to the group, so two people organising the same vault at the same time never
/// collide here.
/// </para>
/// <para>
/// <b>The parent merges as a scalar, and that is what admits a cycle.</b> Two clients re-parenting A under
/// B and B under A while offline each produce a locally sensible group, and there is nothing here that can
/// see the pair. Resolving it would mean a merge that reads the whole group list, which the merge does not
/// have and should not grow — this layer resolves one item against one item. The cycle is contained where
/// it is walked instead: see <see cref="HostGroupSecret.ParentId"/>.
/// </para>
/// <para>
/// Nothing is redacted. The name is the one thing a group has, and a notice saying only that "the name
/// differed" would leave the user unable to tell which of their two names survived. The ids are shown for
/// the same reason they are on a host — an id names a vault item, it is not the secret inside it.
/// </para>
/// </remarks>
public static class HostGroupSecretMerge
{
    /// <summary>Produces the merged group.</summary>
    /// <param name="ancestor">The version both sides branched from.</param>
    /// <param name="local">The pending local version.</param>
    /// <param name="remote">The server's current version.</param>
    public static HostGroupMergeResult Merge(
        HostGroupSecret ancestor,
        HostGroupSecret local,
        HostGroupSecret remote)
    {
        ArgumentNullException.ThrowIfNull(ancestor);
        ArgumentNullException.ThrowIfNull(local);
        ArgumentNullException.ThrowIfNull(remote);

        var conflicts = new List<HostFieldConflict>();

        var merged = new HostGroupSecret
        {
            Label = Field(
                nameof(HostGroupSecret.Label),
                ancestor.Label,
                local.Label,
                remote.Label,
                conflicts,
                static label => label,
                StringComparer.Ordinal),

            ParentId = Field(
                nameof(HostGroupSecret.ParentId),
                ancestor.ParentId,
                local.ParentId,
                remote.ParentId,
                conflicts,
                static id => id?.ToString() ?? "no parent"),
        };

        return new HostGroupMergeResult(
            WithDefaults(merged, ancestor, local, remote, conflicts), conflicts);
    }

    /// <summary>
    /// Merges the four values hosts under this group fall back to.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Split out for length, and they do belong together: each is a value a host reads through to when it
    /// leaves its own field unset, and each merges as a plain scalar. The two ids can additionally end up
    /// dangling, exactly as a host's can — handled where the reference is used, not here.
    /// </para>
    /// <para>
    /// <b>The exclusion between the key and the credential is not re-checked here</b>, and it does not need
    /// to be: neither side can hold both, and a scalar merge takes one value per field from one side or the
    /// other, so it cannot manufacture a pair that was not offered. What it can manufacture is a group
    /// defaulting to a key beneath which a host names a credential, and no item merge can see that — it is
    /// the resolver's to enforce.
    /// </para>
    /// </remarks>
    private static HostGroupSecret WithDefaults(
        HostGroupSecret merged,
        HostGroupSecret ancestor,
        HostGroupSecret local,
        HostGroupSecret remote,
        List<HostFieldConflict> conflicts) =>
        merged with
        {
            DefaultPort = Field(
                nameof(HostGroupSecret.DefaultPort),
                ancestor.DefaultPort,
                local.DefaultPort,
                remote.DefaultPort,
                conflicts,
                static port => port?.ToString(CultureInfo.InvariantCulture) ?? "no default port"),

            DefaultUsername = Field(
                nameof(HostGroupSecret.DefaultUsername),
                ancestor.DefaultUsername,
                local.DefaultUsername,
                remote.DefaultUsername,
                conflicts,
                static username => username ?? "no default user",
                StringComparer.Ordinal),

            DefaultSshKeyId = Field(
                nameof(HostGroupSecret.DefaultSshKeyId),
                ancestor.DefaultSshKeyId,
                local.DefaultSshKeyId,
                remote.DefaultSshKeyId,
                conflicts,
                static id => id?.ToString() ?? "no default key"),

            DefaultCredentialId = Field(
                nameof(HostGroupSecret.DefaultCredentialId),
                ancestor.DefaultCredentialId,
                local.DefaultCredentialId,
                remote.DefaultCredentialId,
                conflicts,
                static id => id?.ToString() ?? "no default credential"),
        };

    /// <remarks>
    /// <para>
    /// The local side always loses a scalar clash — see <see cref="ThreeWayMerge"/> — so the discarded side
    /// is fixed here rather than derived from the outcome.
    /// </para>
    /// <para>
    /// The formatter is handed the discarded value even when that value is null, and the null-forgiving
    /// operator says why that is safe: a conflicted merge always has a discarded value, so a null here is a
    /// nullable field whose discarded value was "unset" rather than a missing one. Every field on this
    /// record but the name has a name for its absence, and short-circuiting on null would mean none of them
    /// could report it.
    /// </para>
    /// </remarks>
    private static T Field<T>(
        string name,
        T ancestor,
        T local,
        T remote,
        List<HostFieldConflict> conflicts,
        Func<T, string?> format,
        IEqualityComparer<T>? comparer = null)
    {
        var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);

        if (merge.IsConflicted)
        {
            conflicts.Add(new HostFieldConflict(
                name,
                MergeSide.Local,
                format(merge.Value),
                format(merge.Discarded!),
                DiscardedWasRemoval: false));
        }

        return merge.Value;
    }
}
