using static DodoSSH.Client.Domain.Tests.HostFactory;

namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// Merging a host field by field.
/// </summary>
/// <remarks>
/// The primitives are covered by <see cref="ThreeWayMergeTests"/>; this is about the wiring — that
/// every field is actually routed through a merge, that the collections use the right strategy, and
/// that a conflict names the field precisely enough for a user to act on it.
/// </remarks>
public sealed class HostSecretMergeTests
{
    [Fact]
    public void NeitherSideChanged_ProducesTheSameHostAndNoConflicts()
    {
        var host = Host();

        var result = HostSecretMerge.Merge(host, host, host);

        result.Merged.ShouldBe(host);
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void EachSideChangedADifferentField_BothSurvive()
    {
        // The reason a field-level merge is worth writing at all.
        var ancestor = Host();
        var local = ancestor with { Notes = "rotate quarterly" };
        var remote = ancestor with { Username = "postgres" };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.Notes.ShouldBe("rotate quarterly");
        result.Merged.Username.ShouldBe("postgres");
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void EveryScalarField_IsRoutedThroughAMerge()
    {
        // A field added to HostSecret but forgotten in the merge would silently revert to the remote
        // value forever. Changing each one only locally proves each is actually consulted.
        var ancestor = Host();

        var local = ancestor with
        {
            Label = "prod-db-1",
            Hostname = "db1.internal",
            Port = 2222,
            Username = "admin",
            Notes = "primary",
            JumpHostIds = JumpChain.Create([Bastion]),
            Options = HostOptions.Create([new HostOption("Compression", "yes")]),
            RelayEnabled = true,
            SshKeyId = DeployKey,
            GroupId = Production,
            TagIds = TagSet.Create([Pci]),
            PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
        };

        var result = HostSecretMerge.Merge(ancestor, local, ancestor);

        result.Merged.ShouldBe(local);
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void TheFieldsTheExclusionsKeepOutOfTheGuardAbove_AreAlsoRoutedThroughAMerge()
    {
        // AsksForPassword cannot sit beside SshKeyId in one valid host, and a null Port cannot sit beside
        // an explicit one — so both get their own pass rather than being the fields the guard silently
        // skips. A forgotten one here means a host put back on a typed password, or set to take its group's
        // port, quietly reverting to the server's copy for ever.
        var ancestor = Host();

        var local = ancestor with { Port = null, AsksForPassword = true };

        var result = HostSecretMerge.Merge(ancestor, local, ancestor);

        result.Merged.ShouldBe(local);
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void TwoPeopleAddingDifferentTagsToOneHost_BothKeepTheirs()
    {
        // The single most visible difference between a field-level merge and last-writer-wins, and the
        // reason TagIds merges per tag rather than as a whole value. A whole-value merge would take one
        // side's set entire and drop the other's.
        var ancestor = Host();

        var result = HostSecretMerge.Merge(
            ancestor,
            ancestor with { TagIds = TagSet.Create([Pci]) },
            ancestor with { TagIds = TagSet.Create([EuWest]) });

        result.Merged.TagIds.ShouldBe(TagSet.Create([Pci, EuWest]));
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void OneSideRemovingATagWhileTheOtherAddsAnother_KeepsBothDecisions()
    {
        // Each tag is resolved on its own, so a removal on one side and an addition on the other are two
        // independent answers rather than two versions of one. A whole-value merge would have to pick.
        var ancestor = Host(tags: [Pci]);

        var result = HostSecretMerge.Merge(
            ancestor,
            ancestor with { TagIds = TagSet.Empty },
            ancestor with { TagIds = TagSet.Create([Pci, EuWest]) });

        result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest]));
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void ATagRemovedOnBothSides_IsNotResurrected()
    {
        var ancestor = Host(tags: [Pci, EuWest]);
        var untagged = ancestor with { TagIds = TagSet.Create([EuWest]) };

        var result = HostSecretMerge.Merge(ancestor, untagged, untagged);

        result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest]));
        result.HasConflicts.ShouldBeFalse();
    }

    /// <remarks>
    /// The property that makes a tag set the one field on a host which can never ask the user anything. A
    /// tag is present or absent, so a key cannot hold two values, so the "both sides moved differently"
    /// branch of the keyed merge is unreachable — see <c>HostSecretMerge.MergeTags</c>. Stated as a table
    /// over every arrangement of one tag, because the claim is about the whole matrix rather than about any
    /// one row of it.
    /// </remarks>
    [Theory]
    [InlineData(true, true, true)]
    [InlineData(true, true, false)]
    [InlineData(true, false, true)]
    [InlineData(true, false, false)]
    [InlineData(false, true, true)]
    [InlineData(false, true, false)]
    [InlineData(false, false, true)]
    [InlineData(false, false, false)]
    public void NoArrangementOfOneTag_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote)
    {
        var result = HostSecretMerge.Merge(
            Host(tags: Wearing(inAncestor)),
            Host(tags: Wearing(inLocal)),
            Host(tags: Wearing(inRemote)));

        result.HasConflicts.ShouldBeFalse();

        // And the outcome is the one a set should give: a side that moved gets its way, because the other
        // one did not move.
        result.Merged.TagIds.Contains(Pci).ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote);
    }

    private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : [];

    [Fact]
    public void TwoPeoplePinningDifferentPathsToOneHost_BothKeepTheirs()
    {
        // The same reason TagIds merges per tag rather than as a whole value: a whole-value merge would
        // take one side's list entire and drop the other's.
        var ancestor = Host();

        var result = HostSecretMerge.Merge(
            ancestor,
            ancestor with { PinnedPaths = PinnedPathList.Create(["/var/www/app"]) },
            ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) });

        result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void PinnedPathAdditions_ComeOutInBaseOrderThenLocalThenRemote()
    {
        // The order guarantee this merge exists to keep: unlike a tag chip, a pinned path's position is
        // part of what the user set, so the merge must produce a deterministic order rather than whatever
        // a set union happens to yield.
        var ancestor = Host(pinnedPaths: ["/base/one", "/base/two"]);

        var result = HostSecretMerge.Merge(
            ancestor,
            ancestor with
            {
                PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/local/added"]),
            },
            ancestor with
            {
                PinnedPaths = PinnedPathList.Create(["/base/one", "/base/two", "/remote/added"]),
            });

        result.Merged.PinnedPaths.ShouldBe(
            PinnedPathList.Create(["/base/one", "/base/two", "/local/added", "/remote/added"]));
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void OneSideRemovingAPinnedPathWhileTheOtherAddsAnother_KeepsBothDecisions()
    {
        // Each path is resolved on its own, so a removal on one side and an addition on the other are two
        // independent answers rather than two versions of one. A whole-value merge would have to pick.
        var ancestor = Host(pinnedPaths: ["/var/www/app"]);

        var result = HostSecretMerge.Merge(
            ancestor,
            ancestor with { PinnedPaths = PinnedPathList.Empty },
            ancestor with
            {
                PinnedPaths = PinnedPathList.Create(["/var/www/app", "/var/log"]),
            });

        result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void APinnedPathRemovedOnBothSides_IsNotResurrected()
    {
        var ancestor = Host(pinnedPaths: ["/var/www/app", "/var/log"]);
        var withoutApp = ancestor with { PinnedPaths = PinnedPathList.Create(["/var/log"]) };

        var result = HostSecretMerge.Merge(ancestor, withoutApp, withoutApp);

        result.Merged.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/log"]));
        result.HasConflicts.ShouldBeFalse();
    }

    /// <inheritdoc cref="NoArrangementOfOneTag_ProducesAConflict" />
    [Theory]
    [InlineData(true, true, true)]
    [InlineData(true, true, false)]
    [InlineData(true, false, true)]
    [InlineData(true, false, false)]
    [InlineData(false, true, true)]
    [InlineData(false, true, false)]
    [InlineData(false, false, true)]
    [InlineData(false, false, false)]
    public void NoArrangementOfOnePinnedPath_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote)
    {
        var result = HostSecretMerge.Merge(
            Host(pinnedPaths: Pinning(inAncestor)),
            Host(pinnedPaths: Pinning(inLocal)),
            Host(pinnedPaths: Pinning(inRemote)));

        result.HasConflicts.ShouldBeFalse();

        result.Merged.PinnedPaths.Contains("/var/www/app")
            .ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote);
    }

    private static string[] Pinning(bool pinned) => pinned ? ["/var/www/app"] : [];

    [Fact]
    public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict()
    {
        // The formatter has to run for the null side, and null here is not an absence — it is the decision
        // to take the group's port. A conflict log printing an empty string in its place would leave the
        // user unable to tell which of the two decisions was dropped.
        var ancestor = Host(port: 22);

        var result = HostSecretMerge.Merge(
            ancestor,
            ancestor with { Port = null },
            ancestor with { Port = 2222 });

        result.Merged.Port.ShouldBe(2222);

        var conflict = result.Conflicts.ShouldHaveSingleItem();

        conflict.Field.ShouldBe(nameof(HostSecret.Port));
        conflict.Kept.ShouldBe("2222");
        conflict.Discarded.ShouldBe("the group's port");
    }

    [Fact]
    public void ARemovedKeyBinding_IsNotResurrectedByTheOtherSide()
    {
        // The other direction, and the one a two-way diff gets wrong: null is a value here, not an absence.
        // A host deliberately put back on a password must not silently regain its key because the server's
        // copy still names one.
        var ancestor = Host(sshKeyId: DeployKey);
        var local = ancestor with { SshKeyId = null };

        var result = HostSecretMerge.Merge(ancestor, local, ancestor);

        result.Merged.SshKeyId.ShouldBeNull();
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void TwoSidesBindingDifferentKeys_NamesBothIdsInTheConflict()
    {
        // An id is not a secret — it names a vault item rather than being the key — so both are shown. The
        // user cannot tell which of two keys was dropped otherwise.
        var other = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04");

        var ancestor = Host();
        var local = ancestor with { SshKeyId = DeployKey };
        var remote = ancestor with { SshKeyId = other };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.SshKeyId.ShouldBe(other);

        var conflict = result.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(HostSecret.SshKeyId));
        conflict.Kept.ShouldBe(other.ToString());
        conflict.Discarded.ShouldBe(DeployKey.ToString());
    }

    [Fact]
    public void ABindingClashingWithItsRemoval_SaysWhichSideHadNoKey()
    {
        // "no key" rather than a blank, for the same reason a clashing port is reported as a number: a
        // conflict entry whose discarded value is empty reads as a bug in the conflict log.
        var ancestor = Host(sshKeyId: DeployKey);
        var local = ancestor with { SshKeyId = null };
        var remote = ancestor with { SshKeyId = Relay };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        var conflict = result.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(HostSecret.SshKeyId));
        conflict.Kept.ShouldBe(Relay.ToString());
        conflict.Discarded.ShouldBe("no key");
    }

    [Fact]
    public void AClashingScalar_TakesRemoteAndNamesTheFieldItDiscarded()
    {
        var ancestor = Host();
        var local = ancestor with { Hostname = "db-mine.internal" };
        var remote = ancestor with { Hostname = "db-theirs.internal" };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.Hostname.ShouldBe("db-theirs.internal");

        var conflict = result.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(HostSecret.Hostname));
        conflict.Kept.ShouldBe("db-theirs.internal");
        conflict.Discarded.ShouldBe("db-mine.internal");
        conflict.DiscardedSide.ShouldBe(MergeSide.Local);
    }

    [Fact]
    public void AClashingPort_IsReportedAsANumberNotAsBlank()
    {
        // Rendering the losing value is the entire point of the conflict record; a non-string field
        // that formatted to nothing would leave the user unable to restore it.
        var ancestor = Host(port: 22);
        var result = HostSecretMerge.Merge(ancestor, ancestor with { Port = 2222 }, ancestor with { Port = 2200 });

        var conflict = result.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(HostSecret.Port));
        conflict.Kept.ShouldBe("2200");
        conflict.Discarded.ShouldBe("2222");
    }

    [Fact]
    public void AJumpChain_MergesAsAWholeRouteRatherThanAsASet()
    {
        // Deliberate, and the opposite of how the directives merge. Unioning two chains would
        // produce a route neither user configured and would silently change which machine is
        // reached through which — so this conflicts instead, and reports the discarded route.
        var ancestor = Host();
        var local = ancestor with { JumpHostIds = JumpChain.Create([Bastion]) };
        var remote = ancestor with { JumpHostIds = JumpChain.Create([Relay]) };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay])).ShouldBeTrue();
        result.Merged.JumpHostIds.Count.ShouldBe(1);

        var conflict = result.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(HostSecret.JumpHostIds));
        conflict.Discarded.ShouldNotBeNull();
        conflict.Discarded.ShouldContain(Bastion.ToString());
    }

    [Fact]
    public void AReorderedJumpChain_IsAChange()
    {
        var ancestor = Host(jumps: [Bastion, Relay]);
        var local = ancestor with { JumpHostIds = JumpChain.Create([Relay, Bastion]) };

        var result = HostSecretMerge.Merge(ancestor, local, ancestor);

        result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay, Bastion])).ShouldBeTrue();
    }

    [Fact]
    public void Directives_MergePerNameSoBothAdditionsSurvive()
    {
        var ancestor = Host();
        var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "yes")]) };
        var remote = ancestor with
        {
            Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
        };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.Options.Count.ShouldBe(2);
        result.Merged.Options.TryGetValue("Compression", out var compression).ShouldBeTrue();
        compression.ShouldBe("yes");
        result.Merged.Options.TryGetValue("ServerAliveInterval", out var keepAlive).ShouldBeTrue();
        keepAlive.ShouldBe("30");
        result.HasConflicts.ShouldBeFalse();
    }

    [Fact]
    public void AClashingDirective_NamesTheDirectiveNotJustTheField()
    {
        // "Options changed" would be useless. The user needs to know which one.
        var ancestor = Host(options: [("Compression", "yes")]);
        var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) };
        var remote = ancestor with
        {
            Options = HostOptions.Create([new HostOption("Compression", "delayed")]),
        };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        var conflict = result.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe("Options[Compression]");
        conflict.Kept.ShouldBe("delayed");
        conflict.Discarded.ShouldBe("no");
    }

    [Fact]
    public void ARemovedDirectiveTheOtherSideEdited_KeepsTheValue()
    {
        var ancestor = Host(options: [("Compression", "yes")]);
        var local = ancestor with { Options = HostOptions.Empty };
        var remote = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.Options.TryGetValue("Compression", out var value).ShouldBeTrue();
        value.ShouldBe("no");
        result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue();
    }

    [Fact]
    public void TheMergedHost_IsAlwaysValidWhenBothInputsWere()
    {
        // A merge that produced an unstorable host would strand the item: it could never be pushed
        // and the conflict could never clear.
        var ancestor = Host();
        var local = ancestor with { Label = "mine", Port = 2222 };
        var remote = ancestor with { Label = "theirs", Hostname = "other.internal" };

        var result = HostSecretMerge.Merge(ancestor, local, remote);

        result.Merged.TryValidate(out var error).ShouldBeTrue(error);
    }

    [Fact]
    public void ResolvingAConflictConverges()
    {
        // Two clients, both merging, must reach the same host and then stop. Re-merging the result
        // against the remote produces no further conflict — which is what stops an endless
        // push-conflict-merge-push loop between two machines.
        var ancestor = Host();
        var local = ancestor with { Notes = "mine", Username = "a" };
        var remote = ancestor with { Notes = "theirs", Hostname = "other.internal" };

        var first = HostSecretMerge.Merge(ancestor, local, remote);
        first.HasConflicts.ShouldBeTrue();

        var second = HostSecretMerge.Merge(remote, first.Merged, remote);

        second.HasConflicts.ShouldBeFalse();
        second.Merged.ShouldBe(first.Merged);
    }
}
