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

namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// Walking a host's group chain for the values it did not state itself.
/// </summary>
/// <remarks>
/// <para>
/// This is the code the connect path runs, so its failures are not cosmetic. A wrong port dials the wrong
/// machine or nothing at all; a wrong binding sends a password to a host set up for key-only access; and a
/// walk that does not terminate is a shell that never opens, which is the one failure a user cannot even
/// describe.
/// </para>
/// <para>
/// The cycle tests are the reason the walk carries a visited set at all. A cycle cannot be created through
/// the editor and cannot be seen by the merge, which resolves one group against one group, or by the
/// server, which cannot read the payload — so it arrives assembled from two offline re-parents or not at
/// all. What these pin is that it degrades to a group reading as a root rather than hanging.
/// </para>
/// </remarks>
public sealed class HostInheritanceTests
{
    private static Guid Estate { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");

    private static Guid Region { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");

    private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");

    [Fact]
    public void AHostThatStatesEverything_InheritsNothing()
    {
        var host = Host(port: 2222, username: "deploy", sshKeyId: DeployKey, groupId: Production);

        var resolved = HostInheritance.Resolve(
            host,
            Groups((Production, Group(defaultPort: 9999, defaultUsername: "root"))));

        resolved.Port.Value.ShouldBe(2222);
        resolved.Port.IsInherited.ShouldBeFalse();
        resolved.Username.Value.ShouldBe("deploy");
        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.SshKey);
        resolved.Binding.EntityId.ShouldBe(DeployKey);
        resolved.Binding.IsInherited.ShouldBeFalse();
    }

    [Fact]
    public void AHostThatStatesNothing_TakesItsGroupsValuesAndSaysWhichGroup()
    {
        // The second half matters as much as the first: the editor draws an inherited value as a
        // placeholder behind an empty box, so it has to know the value came from somewhere else.
        var host = Host(port: null, username: null, groupId: Production);

        var resolved = HostInheritance.Resolve(
            host,
            Groups((Production, Group(
                defaultPort: 2222,
                defaultUsername: "deploy",
                defaultCredentialId: TeamCredential))));

        resolved.Port.Value.ShouldBe(2222);
        resolved.Port.FromGroupId.ShouldBe(Production);
        resolved.Username.Value.ShouldBe("deploy");
        resolved.Username.FromGroupId.ShouldBe(Production);
        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
        resolved.Binding.EntityId.ShouldBe(TeamCredential);
        resolved.Binding.FromGroupId.ShouldBe(Production);
    }

    [Fact]
    public void AHostWithNoGroupAndNothingStated_FallsBackToTwentyTwoAndATypedPassword()
    {
        // Every host stored before any of this existed. Nothing changed underneath them: naming neither a
        // key nor a credential resolved to a typed password then, and resolves to one now.
        var resolved = HostInheritance.Resolve(Host(port: null, username: null), Groups());

        resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
        resolved.Port.IsInherited.ShouldBeFalse();
        resolved.Username.Value.ShouldBeNull();
        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
        resolved.Binding.EntityId.ShouldBeNull();
    }

    [Fact]
    public void TheNearestGroupThatStatesAValue_Wins()
    {
        var host = Host(port: null, username: null, groupId: Production);

        var resolved = HostInheritance.Resolve(
            host,
            Groups(
                (Production, Group(parentId: Region, defaultPort: 2222)),
                (Region, Group(parentId: Estate, defaultPort: 9999, defaultUsername: "deploy")),
                (Estate, Group(defaultUsername: "root"))));

        resolved.Port.Value.ShouldBe(2222);
        resolved.Port.FromGroupId.ShouldBe(Production);

        // Each field is resolved on its own, so a group that answers one question does not stop the walk
        // for the others.
        resolved.Username.Value.ShouldBe("deploy");
        resolved.Username.FromGroupId.ShouldBe(Region);
    }

    [Fact]
    public void AHostWithAnEmptyUsername_HasNoUsernameRatherThanTheGroups()
    {
        // The three states in one nullable string. Null used to mean "no username", which the connect path
        // refuses; it now means "ask the group", so the refusal has to stay reachable or a host under a
        // group could never opt out of the group's user.
        var resolved = HostInheritance.Resolve(
            Host(username: string.Empty, groupId: Production),
            Groups((Production, Group(defaultUsername: "deploy"))));

        resolved.Username.Value.ShouldBe(string.Empty);
        resolved.Username.IsInherited.ShouldBeFalse();
    }

    [Fact]
    public void AHostPinnedToATypedPassword_DoesNotPickUpItsGroupsKey()
    {
        // The failure worth ruling out above all others in this file. A host deliberately put back on a
        // typed password must not silently start authenticating with the fleet's key because somebody set a
        // default on the group above it.
        var resolved = HostInheritance.Resolve(
            Host(asksForPassword: true, groupId: Production),
            Groups((Production, Group(defaultSshKeyId: DeployKey))));

        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
        resolved.Binding.EntityId.ShouldBeNull();
        resolved.Binding.IsInherited.ShouldBeFalse();
    }

    [Fact]
    public void AHostNamingACredentialUnderAGroupNamingAKey_ResolvesToOneBindingAndItIsTheHosts()
    {
        // Two individually valid records that per-record validation cannot catch, because neither one is
        // wrong on its own. The exclusion is enforced again here, and the nearer statement wins.
        var resolved = HostInheritance.Resolve(
            Host(credentialId: TeamCredential, groupId: Production),
            Groups((Production, Group(defaultSshKeyId: DeployKey))));

        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
        resolved.Binding.EntityId.ShouldBe(TeamCredential);
    }

    [Fact]
    public void ANearerGroupsBinding_BeatsAFurtherOnesEvenWhenTheyDiffer()
    {
        var resolved = HostInheritance.Resolve(
            Host(port: null, groupId: Production),
            Groups(
                (Production, Group(parentId: Estate, defaultCredentialId: TeamCredential)),
                (Estate, Group(defaultSshKeyId: DeployKey))));

        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
        resolved.Binding.FromGroupId.ShouldBe(Production);
    }

    [Fact]
    public void AGroupDeletedOnAnotherMachine_LeavesTheHostUngroupedRatherThanFailing()
    {
        // A dangling id is the ordinary outcome of a delete, not a corruption: deleting a group does not
        // rewrite the hosts that named it, deliberately. See HostGroupRepository.
        var resolved = HostInheritance.Resolve(Host(port: null, username: null, groupId: Production), Groups());

        resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
        resolved.Username.Value.ShouldBeNull();
        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
    }

    [Fact]
    public void AGroupWhoseParentWasDeleted_StopsAtThatGroupRatherThanFailing()
    {
        var resolved = HostInheritance.Resolve(
            Host(port: null, username: null, groupId: Production),
            Groups((Production, Group(parentId: Estate, defaultPort: 2222))));

        resolved.Port.Value.ShouldBe(2222);
        resolved.Username.Value.ShouldBeNull();
    }

    [Fact]
    public void ATwoGroupCycle_ResolvesRatherThanHanging()
    {
        // The state the old "groups are flat" decision existed to prevent, arriving anyway: two clients each
        // re-parenting A under B and B under A while offline. The merge sees one group against one group and
        // the server sees ciphertext, so nothing upstream can refuse the pair — which is why the answer is a
        // walk that terminates rather than a state that cannot occur.
        var resolved = HostInheritance.Resolve(
            Host(port: null, username: null, groupId: Production),
            Groups(
                (Production, Group(parentId: Region, defaultPort: 2222)),
                (Region, Group(parentId: Production, defaultUsername: "deploy"))));

        // Both groups are still read once, because the repeat is what stops the walk rather than the loop
        // being detected up front. What must not happen is a third visit.
        resolved.Port.Value.ShouldBe(2222);
        resolved.Username.Value.ShouldBe("deploy");
    }

    [Fact]
    public void AGroupThatIsItsOwnParent_ReadsAsARoot()
    {
        // Not creatable through the editor and not knowable to HostGroupSecret.TryValidate, which sees the
        // payload and not the item id. Contained here instead, in the same visited set that has to contain
        // a longer cycle anyway.
        var chain = HostInheritance
            .Chain(Production, Groups((Production, Group(parentId: Production))))
            .ToList();

        chain.Count.ShouldBe(1);
        chain[0].Id.ShouldBe(Production);
    }

    [Fact]
    public void ACycleAboveAHost_StillResolvesEveryFieldItCanReach()
    {
        // The degradation the design accepts: defaults past the repeat are unresolved, everything before it
        // is not, and clearing the parent in the editor is the repair. What it must never be is a connect
        // that never returns.
        var resolved = HostInheritance.Resolve(
            Host(port: null, username: null, groupId: Production),
            Groups(
                (Production, Group(parentId: Region)),
                (Region, Group(parentId: Production))));

        resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
        resolved.Username.Value.ShouldBeNull();
        resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
    }

    [Fact]
    public void TheChain_YieldsNearestFirstAndStopsAtTheRoot()
    {
        var chain = HostInheritance
            .Chain(
                Production,
                Groups(
                    (Production, Group(label: "production", parentId: Region)),
                    (Region, Group(label: "eu-west", parentId: Estate)),
                    (Estate, Group(label: "estate"))))
            .ToList();

        chain.Select(entry => entry.Group.Label).ShouldBe(["production", "eu-west", "estate"]);
    }

    [Fact]
    public void TheChainOfAnUngroupedHost_IsEmpty()
    {
        HostInheritance.Chain(null, Groups()).ShouldBeEmpty();
    }

    private static Dictionary<Guid, HostGroupSecret> Groups(
        params (Guid Id, HostGroupSecret Group)[] groups) =>
        groups.ToDictionary(entry => entry.Id, entry => entry.Group);
}
