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 TryValidate_RejectsWhatCannotBeStored()
    {
        Host(label: "").TryValidate(out _).ShouldBeFalse();
        Host(hostname: " ").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().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();
    }
}
