namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// The merge primitives.
/// </summary>
/// <remarks>
/// These are the rules the whole sync story rests on, so they are tested as rules rather than
/// through the sync engine: every triple of (ancestor, local, remote) states is enumerated, and each
/// asserts both the surviving value and — where a side lost — that the losing value came back.
/// </remarks>
public sealed class ThreeWayMergeTests
{
    // ---- Scalar ----

    [Fact]
    public void NeitherSideChanged_IsAgreement()
    {
        var merge = ThreeWayMerge.Scalar("base", "base", "base");

        merge.Value.ShouldBe("base");
        merge.Decision.ShouldBe(MergeDecision.Agreed);
        merge.IsConflicted.ShouldBeFalse();
    }

    [Fact]
    public void OnlyLocalChanged_KeepsTheLocalValue()
    {
        var merge = ThreeWayMerge.Scalar("base", "mine", "base");

        merge.Value.ShouldBe("mine");
        merge.Decision.ShouldBe(MergeDecision.TookLocal);
    }

    [Fact]
    public void OnlyRemoteChanged_KeepsTheRemoteValue()
    {
        var merge = ThreeWayMerge.Scalar("base", "base", "theirs");

        merge.Value.ShouldBe("theirs");
        merge.Decision.ShouldBe(MergeDecision.TookRemote);
    }

    [Fact]
    public void BothSidesMadeTheSameChange_IsAgreementRatherThanAConflict()
    {
        // Two people fixing the same typo must not be asked to arbitrate.
        var merge = ThreeWayMerge.Scalar("base", "fixed", "fixed");

        merge.Value.ShouldBe("fixed");
        merge.Decision.ShouldBe(MergeDecision.Agreed);
        merge.IsConflicted.ShouldBeFalse();
    }

    [Fact]
    public void BothSidesChangedDifferently_TakesRemoteAndReportsLocal()
    {
        // Remote wins so that every replica resolves the same triple identically; without a fixed
        // winner two clients each keep their own value and push over each other forever.
        var merge = ThreeWayMerge.Scalar("base", "mine", "theirs");

        merge.Value.ShouldBe("theirs");
        merge.Decision.ShouldBe(MergeDecision.Conflicted);
        merge.IsConflicted.ShouldBeTrue();

        // The whole justification for picking a side: the other one is handed back, never dropped.
        merge.Discarded.ShouldBe("mine");
    }

    [Fact]
    public void AConflictedMerge_IsIdempotentOnceResolved()
    {
        // Convergence, spelled out. Having taken the remote value, re-merging against the same
        // remote must be agreement rather than a fresh conflict — otherwise the two clients
        // ping-pong.
        var first = ThreeWayMerge.Scalar("base", "mine", "theirs");
        var second = ThreeWayMerge.Scalar("theirs", first.Value, "theirs");

        second.Decision.ShouldBe(MergeDecision.Agreed);
        second.Value.ShouldBe("theirs");
    }

    [Fact]
    public void Scalar_UsesTheSuppliedComparer()
    {
        // Ordinal by default would call these a conflict; the comparer is how a field opts out.
        var merge = ThreeWayMerge.Scalar("base", "SAME", "same", StringComparer.OrdinalIgnoreCase);

        merge.Decision.ShouldBe(MergeDecision.Agreed);
    }

    [Fact]
    public void Scalar_HandlesNullOnAnySide()
    {
        // Nullable fields are the common case — Username and Notes are both optional — so a null
        // must be an ordinary value here rather than a special case that throws.
        ThreeWayMerge.Scalar<string?>(null, "set", null).Value.ShouldBe("set");
        ThreeWayMerge.Scalar<string?>("was", null, "was").Value.ShouldBeNull();
        ThreeWayMerge.Scalar<string?>(null, null, null).Decision.ShouldBe(MergeDecision.Agreed);
    }

    // ---- Map ----

    [Fact]
    public void EachSideAddedADifferentKey_KeepsBoth()
    {
        // The single most visible benefit of a per-key merge over comparing whole collections: two
        // people adding different directives to one host both keep theirs.
        var merge = Map(
            ancestor: [],
            local: [("Compression", "yes")],
            remote: [("ServerAliveInterval", "30")]);

        merge.Merged.Count.ShouldBe(2);
        merge.Merged["Compression"].ShouldBe("yes");
        merge.Merged["ServerAliveInterval"].ShouldBe("30");
        merge.Conflicts.ShouldBeEmpty();
    }

    [Fact]
    public void EachSideAddedTheSameKeyDifferently_TakesRemoteAndReportsLocal()
    {
        var merge = Map(
            ancestor: [],
            local: [("Port", "2222")],
            remote: [("Port", "2200")]);

        merge.Merged["Port"].ShouldBe("2200");

        var conflict = merge.Conflicts.ShouldHaveSingleItem();
        conflict.Key.ShouldBe("Port");
        conflict.Kept.ShouldBe("2200");
        conflict.Discarded.ShouldBe("2222");
        conflict.DiscardedSide.ShouldBe(MergeSide.Local);
        conflict.DiscardedWasRemoval.ShouldBeFalse();
    }

    [Fact]
    public void OneSideRemovedAKeyTheOtherLeftAlone_RemovesIt()
    {
        Map(
            ancestor: [("Compression", "yes")],
            local: [],
            remote: [("Compression", "yes")])
            .Merged.ShouldBeEmpty();

        Map(
            ancestor: [("Compression", "yes")],
            local: [("Compression", "yes")],
            remote: [])
            .Merged.ShouldBeEmpty();
    }

    [Fact]
    public void RemoteEditedAKeyLocalRemoved_KeepsTheEditAndReportsTheRemoval()
    {
        // An edit outlives a removal in both directions. Re-applying a removal costs one click;
        // a discarded value may be the only copy of something the user cannot reconstruct.
        var merge = Map(
            ancestor: [("Compression", "yes")],
            local: [],
            remote: [("Compression", "no")]);

        merge.Merged["Compression"].ShouldBe("no");

        var conflict = merge.Conflicts.ShouldHaveSingleItem();
        conflict.DiscardedSide.ShouldBe(MergeSide.Local);
        conflict.DiscardedWasRemoval.ShouldBeTrue();
        conflict.Kept.ShouldBe("no");
    }

    [Fact]
    public void LocalEditedAKeyRemoteRemoved_KeepsTheEditAndReportsTheRemoval()
    {
        var merge = Map(
            ancestor: [("Compression", "yes")],
            local: [("Compression", "no")],
            remote: []);

        merge.Merged["Compression"].ShouldBe("no");

        var conflict = merge.Conflicts.ShouldHaveSingleItem();

        // The overridden side is the remote one here, which is what makes this asymmetric from the
        // scalar rule: the tie-break is "a value beats an absence" before it is "remote wins".
        conflict.DiscardedSide.ShouldBe(MergeSide.Remote);
        conflict.DiscardedWasRemoval.ShouldBeTrue();
    }

    [Fact]
    public void BothSidesRemovedTheSameKey_IsAgreement()
    {
        var merge = Map(
            ancestor: [("Compression", "yes")],
            local: [],
            remote: []);

        merge.Merged.ShouldBeEmpty();
        merge.Conflicts.ShouldBeEmpty();
    }

    [Fact]
    public void UnchangedKeys_SurviveAlongsideConflictingOnes()
    {
        // A conflict on one key must not disturb its neighbours, which is the difference between
        // field-level merge and replacing the collection.
        var merge = Map(
            ancestor: [("Keep", "same"), ("Fight", "base")],
            local: [("Keep", "same"), ("Fight", "mine")],
            remote: [("Keep", "same"), ("Fight", "theirs")]);

        merge.Merged["Keep"].ShouldBe("same");
        merge.Merged["Fight"].ShouldBe("theirs");
        merge.Conflicts.ShouldHaveSingleItem().Key.ShouldBe("Fight");
    }

    [Fact]
    public void Map_TreatsKeysUnderTheSuppliedComparer()
    {
        // SSH keywords are case-insensitive. Treating these as two keys would let a host carry
        // both Compression and compression, which no client could then reconcile.
        var merge = Map(
            ancestor: [("Compression", "yes")],
            local: [("compression", "yes")],
            remote: [("COMPRESSION", "yes")]);

        merge.Merged.Count.ShouldBe(1);
        merge.Conflicts.ShouldBeEmpty();
    }

    [Fact]
    public void Map_NeverDropsAValueWithoutReportingIt()
    {
        // The invariant, asserted directly rather than inferred from the cases above: every value
        // present on either side either survives into the merge or appears in the conflict list.
        var local = new[] { ("A", "1"), ("B", "2"), ("C", "3") };
        var remote = new[] { ("A", "9"), ("B", "2"), ("D", "4") };

        var merge = Map(ancestor: [("A", "0"), ("B", "2")], local: local, remote: remote);

        foreach (var (key, value) in local.Concat(remote))
        {
            var survived = merge.Merged.TryGetValue(key, out var kept)
                && string.Equals(kept, value, StringComparison.Ordinal);

            var reported = merge.Conflicts.Any(c =>
                HostOption.NameComparer.Equals(c.Key, key)
                && string.Equals(c.Discarded, value, StringComparison.Ordinal));

            (survived || reported).ShouldBeTrue($"{key}={value} was neither kept nor reported.");
        }
    }

    private static MapMerge<string, string> Map(
        (string Key, string Value)[] ancestor,
        (string Key, string Value)[] local,
        (string Key, string Value)[] remote) =>
        ThreeWayMerge.Map(
            ToMap(ancestor),
            ToMap(local),
            ToMap(remote),
            HostOption.NameComparer,
            StringComparer.Ordinal);

    private static Dictionary<string, string> ToMap((string Key, string Value)[] entries)
    {
        var map = new Dictionary<string, string>(HostOption.NameComparer);

        foreach (var (key, value) in entries)
        {
            map[key] = value;
        }

        return map;
    }
}
