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

namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// Equality of the collection types, and of the host that holds them.
/// </summary>
/// <remarks>
/// This suite guards a failure that would be invisible rather than loud. If any of these compared by
/// reference, the merge would report every host as changed on every sync pass, two identical edits
/// would register as a conflict, and the engine would push spurious updates forever. Nothing would
/// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here.
/// </remarks>
public sealed class ValueSemanticsTests
{
    [Fact]
    public void TwoHostsWithEqualContents_AreEqual()
    {
        var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
        var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);

        one.ShouldBe(other);
        one.GetHashCode().ShouldBe(other.GetHashCode());
    }

    [Fact]
    public void AHostDifferingOnlyInACollection_IsNotEqual()
    {
        Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay]));
        Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")]));
    }

    // Note on the assertion style below: these call Equals and the operators directly rather than
    // going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares
    // enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one
    // thing this suite exists to check.

    [Fact]
    public void AJumpChain_ComparesByContentsAndOrder()
    {
        JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
        (JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue();

        JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse();
        JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse();

        JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue();
        JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse();
    }

    [Fact]
    public void AJumpChain_HashesByContents()
    {
        JumpChain.Create([Bastion, Relay]).GetHashCode()
            .ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode());
    }

    [Fact]
    public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories()
    {
        Guid[] hops = [Bastion, Relay];

        JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue();
    }

    [Fact]
    public void Directives_CompareIgnoringNameCaseAndInputOrder()
    {
        // Both halves matter. Case, because a merge picks whichever spelling it saw first and two
        // clients must still agree. Order, because the collection canonicalises and a user typing
        // the same two directives in the other order has not changed anything.
        var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
        var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]);

        one.Equals(other).ShouldBeTrue();
        (one == other).ShouldBeTrue();
        one.GetHashCode().ShouldBe(other.GetHashCode());
    }

    [Fact]
    public void Directives_CompareValuesCaseSensitively()
    {
        // Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the
        // same to sshd, but this layer must not decide that for every directive that exists.
        HostOptions.Create([new HostOption("Compression", "yes")])
            .Equals(HostOptions.Create([new HostOption("Compression", "YES")]))
            .ShouldBeFalse();
    }

    [Fact]
    public void Directives_CompareUnequalWhenOneSideHasMore()
    {
        var one = HostOptions.Create([new HostOption("Compression", "yes")]);
        var other = HostOptions.Create(
            [new HostOption("Compression", "yes"), new HostOption("Port", "22")]);

        one.Equals(other).ShouldBeFalse();
        HostOptions.Empty.Equals(one).ShouldBeFalse();
        one.Equals(null).ShouldBeFalse();
    }

    [Fact]
    public void Directives_AreHeldInNameOrder()
    {
        var options = HostOptions.Create(
        [
            new HostOption("ServerAliveInterval", "30"),
            new HostOption("Compression", "yes"),
        ]);

        options[0].Name.ShouldBe("Compression");
        options[1].Name.ShouldBe("ServerAliveInterval");
    }

    [Fact]
    public void ARepeatedDirectiveName_IsRefused()
    {
        // A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest
        // answer; silently keeping one of the two would lose data without saying so.
        var duplicate = new[]
        {
            new HostOption("Compression", "yes"),
            new HostOption("compression", "no"),
        };

        HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse();
        error.ShouldNotBeNull();
        error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue();

        Should.Throw<ArgumentException>(() => HostOptions.Create(duplicate));
    }

    [Fact]
    public void ABlankDirectiveName_IsRefused()
    {
        HostOptions.TryCreate([new HostOption("  ", "x")], out _, out _).ShouldBeFalse();
    }

    [Fact]
    public void AHostBuiltWithWith_KeepsCollectionEquality()
    {
        // `with` copies the collection references, so this would pass even under reference equality.
        // It is here because the merge builds its result with an object initialiser rather than
        // `with`, and both paths have to agree.
        var host = Host(options: [("Compression", "yes")]);
        var copy = host with { Notes = "changed" };

        copy.Options.Equals(host.Options).ShouldBeTrue();
        (copy with { Notes = host.Notes }).ShouldBe(host);
    }

    [Fact]
    public void ATagSet_ComparesByContentsAndNotByOrder()
    {
        // The half that differs from a jump chain, and the reason it is a separate type. A route reordered
        // is a different route; a tag list reordered is the same host, so two users who tapped the same two
        // chips in opposite orders must produce one value and nothing to push.
        TagSet.Create([Pci, EuWest]).Equals(TagSet.Create([EuWest, Pci])).ShouldBeTrue();
        (TagSet.Create([Pci, EuWest]) == TagSet.Create([EuWest, Pci])).ShouldBeTrue();

        TagSet.Create([Pci, EuWest]).GetHashCode()
            .ShouldBe(TagSet.Create([EuWest, Pci]).GetHashCode());

        TagSet.Create([Pci]).Equals(TagSet.Create([EuWest])).ShouldBeFalse();
        TagSet.Create([Pci]).Equals(TagSet.Create([Pci, EuWest])).ShouldBeFalse();

        TagSet.Create([]).Equals(TagSet.Empty).ShouldBeTrue();
        TagSet.Create([Pci]).Equals(null).ShouldBeFalse();
    }

    [Fact]
    public void ATagSet_CollapsesARepeatedTag()
    {
        // A repeat arrives from a payload some other client wrote. Left alone it would compare unequal to
        // the same set written once, and the engine would push the host as changed on every pass for ever.
        TagSet.Create([Pci, Pci]).Equals(TagSet.Create([Pci])).ShouldBeTrue();
        TagSet.Create([Pci, Pci]).Count.ShouldBe(1);
    }

    [Fact]
    public void ATagSet_MapsToItsOwnIdsSoAKeyedMergeIsASetMerge()
    {
        // The value repeats the key on purpose: no key can then hold two different values, so the only
        // disagreement a keyed merge can report is one side adding what the other removed.
        var map = TagSet.Create([Pci, EuWest]).ToIdMap();

        map.Keys.Order().ShouldBe(new[] { Pci, EuWest }.Order());
        map[Pci].ShouldBe(Pci);
        map[EuWest].ShouldBe(EuWest);
    }

    [Fact]
    public void APinnedPathList_ComparesByContentsAndOrder()
    {
        // The half that differs from a tag set and matches a jump chain: a pinned-path list is what a
        // shortcut menu draws top to bottom, so reordering it is a change, not a no-op.
        PinnedPathList.Create(["/var/log", "/var/www/app"])
            .Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
            .ShouldBeTrue();

        (PinnedPathList.Create(["/var/log", "/var/www/app"])
            == PinnedPathList.Create(["/var/log", "/var/www/app"])).ShouldBeTrue();

        PinnedPathList.Create(["/var/log", "/var/www/app"])
            .Equals(PinnedPathList.Create(["/var/www/app", "/var/log"]))
            .ShouldBeFalse();

        PinnedPathList.Create(["/var/log"])
            .Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
            .ShouldBeFalse();

        PinnedPathList.Create([]).Equals(PinnedPathList.Empty).ShouldBeTrue();
        PinnedPathList.Create(["/var/log"]).Equals(null).ShouldBeFalse();
    }

    [Fact]
    public void APinnedPathList_HashesByContentsAndOrder()
    {
        PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode()
            .ShouldBe(PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode());
    }

    [Fact]
    public void APinnedPathList_ComparesPathsOrdinally()
    {
        // A remote path is meaningful only to the far end, and that end may run a case-sensitive
        // filesystem — so folding case here would be a decision this client has no business making.
        PinnedPathList.Create(["/var/log"]).Equals(PinnedPathList.Create(["/VAR/LOG"])).ShouldBeFalse();
    }

    [Fact]
    public void APinnedPathList_CollapsesARepeatedPathKeepingTheFirstOccurrence()
    {
        // A repeat arrives from a payload some other client wrote, or from the same path pinned twice on
        // one machine. Left alone it would compare unequal to the same list written once, and the engine
        // would push the host as changed on every pass for ever.
        PinnedPathList.Create(["/var/log", "/var/log"]).Equals(PinnedPathList.Create(["/var/log"]))
            .ShouldBeTrue();

        PinnedPathList.Create(["/var/log", "/var/log"]).Count.ShouldBe(1);

        // The first occurrence survives rather than the last, so a later repeat cannot silently move an
        // earlier entry to a new position in the list.
        PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"])[0].ShouldBe("/var/log");
        PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"]).Count.ShouldBe(2);
    }

    [Fact]
    public void APinnedPathList_MapsToItsOwnPathsInListOrder()
    {
        // The value repeats the key on purpose, exactly as TagSet.ToIdMap's does: no key can then hold two
        // different values, so the only disagreement a keyed merge can report is one side adding what the
        // other removed. Unlike TagSet, the map is built in list order rather than a canonical one, which
        // is what lets the merge reproduce "base order, then additions".
        var map = PinnedPathList.Create(["/var/log", "/var/www/app"]).ToPathMap();

        map.Keys.ShouldBe(new[] { "/var/log", "/var/www/app" });
        map["/var/log"].ShouldBe("/var/log");
        map["/var/www/app"].ShouldBe("/var/www/app");
    }

    [Fact]
    public void TryValidate_RejectsWhatCannotBeStored()
    {
        Host(label: "").TryValidate(out _).ShouldBeFalse();
        Host(hostname: " ").TryValidate(out _).ShouldBeFalse();
        Host(port: 0).TryValidate(out _).ShouldBeFalse();
        Host(port: 65536).TryValidate(out _).ShouldBeFalse();
        Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
        Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
        Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
        Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
        Host(pinnedPaths: [" "]).TryValidate(out _).ShouldBeFalse();
        Host(pinnedPaths: ["/has\0nul"]).TryValidate(out _).ShouldBeFalse();

        // Null is "take the group's port", not an absent one, and it has to be storable — it is the whole
        // of what inheritance stores.
        Host(port: null).TryValidate(out _).ShouldBeTrue();
        Host().TryValidate(out _).ShouldBeTrue();

        // A relative path is exactly as valid as an absolute one: it resolves against the account's home,
        // which this client never needs to know.
        Host(pinnedPaths: ["relative/within/home"]).TryValidate(out _).ShouldBeTrue();
    }

    [Fact]
    public void TryValidate_BoundsThePinnedPaths()
    {
        Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength)])
            .TryValidate(out _).ShouldBeTrue();

        Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength + 1)])
            .TryValidate(out var tooLong).ShouldBeFalse();
        tooLong.ShouldNotBeNull().ShouldContain("longer than");

        var atLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths)
            .Select(i => $"/path/{i}")
            .ToArray();
        Host(pinnedPaths: atLimit).TryValidate(out _).ShouldBeTrue();

        var overLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths + 1)
            .Select(i => $"/path/{i}")
            .ToArray();
        Host(pinnedPaths: overLimit).TryValidate(out var tooMany).ShouldBeFalse();
        tooMany.ShouldNotBeNull().ShouldContain("cannot pin more than");
    }

    [Fact]
    public void AHostGivesOneAnswerAboutAuthentication_NotTwo()
    {
        // The same failure the key-or-credential rule catches, in the direction inheritance opened: a host
        // that names a key and also says "ask me for a password" has answered one question twice, and the
        // interface, the connect path and the user would each be free to pick a different answer.
        var both = Host(sshKeyId: DeployKey, asksForPassword: true);

        both.TryValidate(out var reason).ShouldBeFalse();
        reason.ShouldNotBeNull().ShouldContain("not both");

        Host(asksForPassword: true).TryValidate(out _).ShouldBeTrue();
        Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
    }

    [Fact]
    public void AHostAuthenticatesOneWay_NotTwo()
    {
        // SSH would happily try a key and fall back to a password, and a host that named both would leave
        // "how does this authenticate?" without a single answer — so the interface, the connect path and the
        // user would each be free to guess differently. Refused at the type instead.
        var both = Host(sshKeyId: DeployKey, credentialId: Guid.CreateVersion7());

        both.TryValidate(out var reason).ShouldBeFalse();
        reason.ShouldNotBeNull().ShouldContain("not both");

        Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
        Host(credentialId: Guid.CreateVersion7()).TryValidate(out _).ShouldBeTrue();
    }
}
