using System.Globalization;

namespace DodoSSH.Client.Domain;

/// <summary>
/// A value the merge had to override, kept so the user can see it and put it back.
/// </summary>
/// <remarks>
/// This record is the reason the merge is allowed to pick a winner at all. Choosing a side is only
/// acceptable because the other side is preserved verbatim and surfaced; without that, a
/// field-level merge is just last-writer-wins with extra steps.
/// </remarks>
/// <param name="Field">
/// Which field, as a path. A directive reads <c>Options[ServerAliveInterval]</c> so the user is told
/// which one rather than merely that "options" changed.
/// </param>
/// <param name="DiscardedSide">Whose intent was overridden.</param>
/// <param name="Kept">The value that survives, rendered for display.</param>
/// <param name="Discarded">The value that lost, rendered for display.</param>
/// <param name="DiscardedWasRemoval">
/// True when what lost was a deletion rather than a different value.
/// </param>
public sealed record HostFieldConflict(
    string Field,
    MergeSide DiscardedSide,
    string? Kept,
    string? Discarded,
    bool DiscardedWasRemoval);

/// <summary>The merged host, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The host to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record HostMergeResult(
    HostSecret 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 host against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Called when a pull brings down a change to an item that also has a local edit pending, and again
/// when a push comes back <c>Conflict</c> carrying the server's current row. Both paths need the
/// same answer, so both go through here.
/// </para>
/// <para>
/// Scalar fields defer to the server on a genuine clash and the jump chain merges as a whole value,
/// because its order is its meaning. Directives merge per name, which is what lets two people each
/// add one and both keep it. See <see cref="ThreeWayMerge"/> for why the remote side wins.
/// </para>
/// </remarks>
public static class HostSecretMerge
{
    /// <summary>
    /// Produces the merged host.
    /// </summary>
    /// <param name="ancestor">
    /// The version both sides branched from — the ciphertext the client retained when it queued its
    /// local edit. Without it this degrades to a two-way diff, which cannot tell an edit from a
    /// revert and so cannot avoid resurrecting deleted values.
    /// </param>
    /// <param name="local">The pending local version.</param>
    /// <param name="remote">The server's current version.</param>
    public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
    {
        ArgumentNullException.ThrowIfNull(ancestor);
        ArgumentNullException.ThrowIfNull(local);
        ArgumentNullException.ThrowIfNull(remote);

        var conflicts = new List<HostFieldConflict>();

        var merged = new HostSecret
        {
            Label = Text(nameof(HostSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts),
            Hostname = Text(
                nameof(HostSecret.Hostname), ancestor.Hostname, local.Hostname, remote.Hostname, conflicts),
            Port = Field(
                nameof(HostSecret.Port),
                ancestor.Port,
                local.Port,
                remote.Port,
                conflicts,
                static port => port?.ToString(CultureInfo.InvariantCulture) ?? "the group's port"),
            Username = Text(
                nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
            Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
            JumpHostIds = Field(
                nameof(HostSecret.JumpHostIds),
                ancestor.JumpHostIds,
                local.JumpHostIds,
                remote.JumpHostIds,
                conflicts,
                FormatChain),
            Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
            TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
            PinnedPaths = MergePinnedPaths(
                ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
            RelayEnabled = Field(
                nameof(HostSecret.RelayEnabled),
                ancestor.RelayEnabled,
                local.RelayEnabled,
                remote.RelayEnabled,
                conflicts,
                static enabled => enabled ? "enabled" : "disabled"),
        };

        return new HostMergeResult(
            WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
    }

    /// <summary>
    /// Merges the three ids a host can point at: its key, its credential and its group.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Split out for length, and they do belong together: each is a reference to another vault item, each
    /// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
    /// another machine. None of that is the merge's problem — it is handled where the reference is used.
    /// </para>
    /// <para>
    /// <b>The ids are shown in a clash rather than redacted.</b> An id is not a secret — it names a vault
    /// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
    /// merge dropped.
    /// </para>
    /// </remarks>
    private static HostSecret WithReferences(
        HostSecret merged,
        HostSecret ancestor,
        HostSecret local,
        HostSecret remote,
        List<HostFieldConflict> conflicts) =>
        merged with
        {
            SshKeyId = Field(
                nameof(HostSecret.SshKeyId),
                ancestor.SshKeyId,
                local.SshKeyId,
                remote.SshKeyId,
                conflicts,
                static id => id?.ToString() ?? "no key"),

            CredentialId = Field(
                nameof(HostSecret.CredentialId),
                ancestor.CredentialId,
                local.CredentialId,
                remote.CredentialId,
                conflicts,
                static id => id?.ToString() ?? "no credential"),

            GroupId = Field(
                nameof(HostSecret.GroupId),
                ancestor.GroupId,
                local.GroupId,
                remote.GroupId,
                conflicts,
                static id => id?.ToString() ?? "ungrouped"),

            AsksForPassword = Field(
                nameof(HostSecret.AsksForPassword),
                ancestor.AsksForPassword,
                local.AsksForPassword,
                remote.AsksForPassword,
                conflicts,
                static asked => asked is true ? "a typed password" : "the group's binding"),
        };

    private static string Text(
        string name,
        string? ancestor,
        string? local,
        string? remote,
        List<HostFieldConflict> conflicts) =>
        Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!;

    /// <remarks>
    /// <para>
    /// A scalar clash always overrides the local side — see <see cref="ThreeWayMerge"/> — so the
    /// discarded side is fixed here rather than derived.
    /// </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. Short-circuiting on null
    /// instead — which this did — meant the formatter never ran for exactly that case, so a field whose
    /// absence has a name could not report it and the conflict log showed an empty string in its place.
    /// </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;
    }

    private static HostOptions MergeOptions(
        HostOptions ancestor,
        HostOptions local,
        HostOptions remote,
        List<HostFieldConflict> conflicts)
    {
        var merge = ThreeWayMerge.Map(
            ancestor.ToNameMap(),
            local.ToNameMap(),
            remote.ToNameMap(),
            HostOption.NameComparer,
            StringComparer.Ordinal);

        foreach (var conflict in merge.Conflicts)
        {
            conflicts.Add(new HostFieldConflict(
                $"{nameof(HostSecret.Options)}[{conflict.Key}]",
                conflict.DiscardedSide,
                conflict.Kept,
                conflict.Discarded,
                conflict.DiscardedWasRemoval));
        }

        // The merged map is keyed by the same comparer, so uniqueness already holds and Create
        // cannot throw here.
        return HostOptions.Create(
            merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
    }

    /// <summary>
    /// Merges the tag set per tag, so two people each adding a different one both keep theirs.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The reason <see cref="TagSet"/> exposes a map at all. A whole-value merge here would take one side's
    /// set entire and drop the other's — so a colleague tagging a host "pci" while you tagged it "eu-west"
    /// would silently lose one of the two, which is the single most visible difference between a field-level
    /// merge and last-writer-wins.
    /// </para>
    /// <para>
    /// <b>No conflict is reachable, and the loop below is kept anyway.</b> The value in the map is the key,
    /// so a tag can only be present or absent — and running that through
    /// <see cref="ThreeWayMerge.Map"/> leaves nothing to disagree about. Where both sides hold the tag or
    /// neither does they agree; where exactly one side moved, the other still matches the ancestor and the
    /// move is taken. The "both sides moved differently" branch needs one key to hold two values, which this
    /// map cannot express. A set is the one collection shape that merges without ever asking the user.
    /// </para>
    /// <para>
    /// The loop stays because that proof depends on <see cref="TagSet.ToIdMap"/> keying by the value, which
    /// is one edit away from ceasing to be true — and the failure it would cause is a discarded tag that
    /// nothing records. Unreachable code that costs a <c>foreach</c> over an empty list is a cheaper way to
    /// hold that invariant than a comment alone.
    /// </para>
    /// </remarks>
    private static TagSet MergeTags(
        TagSet ancestor,
        TagSet local,
        TagSet remote,
        List<HostFieldConflict> conflicts)
    {
        var merge = ThreeWayMerge.Map(
            ancestor.ToIdMap(),
            local.ToIdMap(),
            remote.ToIdMap(),
            EqualityComparer<Guid>.Default);

        foreach (var conflict in merge.Conflicts)
        {
            conflicts.Add(new HostFieldConflict(
                $"{nameof(HostSecret.TagIds)}[{conflict.Key}]",
                conflict.DiscardedSide,
                conflict.Kept == Guid.Empty ? "not tagged" : "tagged",
                conflict.DiscardedWasRemoval ? "not tagged" : "tagged",
                conflict.DiscardedWasRemoval));
        }

        return TagSet.Create(merge.Merged.Keys);
    }

    /// <summary>
    /// Merges the pinned paths per path, so two people each pinning a different one both keep theirs.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The same reasoning as <see cref="MergeTags"/>, and the same shape: a whole-value merge would take
    /// one side's list entire and drop the other's, so a colleague pinning <c>/var/log</c> while you pinned
    /// <c>/var/www/app</c> would silently lose one of the two.
    /// </para>
    /// <para>
    /// <b>Unlike a tag set, order survives.</b> <see cref="PinnedPathList.ToPathMap"/> is built in list
    /// order rather than a canonical one, and <see cref="ThreeWayMerge.Map"/> unions keys ancestor first,
    /// then local, then remote — so the merged keys come out base order, then this machine's additions,
    /// then the server's, which is what <see cref="PinnedPathList.Create(IEnumerable{string})"/> then
    /// preserves rather than re-sorting.
    /// </para>
    /// <para>
    /// <b>No conflict is reachable here either, for the reason <see cref="MergeTags"/> gives.</b> The value
    /// in the map is the key, so a path can only be present or absent, and the "both sides moved
    /// differently" branch of the keyed merge needs one key to hold two values — which this map cannot
    /// express. The loop stays for the same reason it stays there: the proof depends on
    /// <see cref="PinnedPathList.ToPathMap"/> keying by value, and that is one edit away from no longer
    /// being true.
    /// </para>
    /// </remarks>
    private static PinnedPathList MergePinnedPaths(
        PinnedPathList ancestor,
        PinnedPathList local,
        PinnedPathList remote,
        List<HostFieldConflict> conflicts)
    {
        var merge = ThreeWayMerge.Map(
            ancestor.ToPathMap(),
            local.ToPathMap(),
            remote.ToPathMap(),
            StringComparer.Ordinal);

        foreach (var conflict in merge.Conflicts)
        {
            conflicts.Add(new HostFieldConflict(
                $"{nameof(HostSecret.PinnedPaths)}[{conflict.Key}]",
                conflict.DiscardedSide,
                conflict.Kept is null ? "not pinned" : "pinned",
                conflict.DiscardedWasRemoval ? "not pinned" : "pinned",
                conflict.DiscardedWasRemoval));
        }

        return PinnedPathList.Create(merge.Merged.Keys);
    }

    private static string FormatChain(JumpChain chain) =>
        chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
}
