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

namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// A group: a name, a parent, and the four things hosts under it fall back to.
/// </summary>
/// <remarks>
/// <para>
/// A member list is still absent and still for the reason it always was — membership is a pointer on each
/// host, so two people filing two machines into one group is two writes to two items. A parent is present,
/// and it was not: see <see cref="HostGroupSecret"/> for why nesting stopped being worth refusing once the
/// defaults made the chain something the connect path had to walk anyway.
/// </para>
/// <para>
/// What these pin is that the envelope round-trips, that a nameless or unstorable group cannot be stored,
/// that a group carrying none of the new fields still encodes at version 1 byte for byte, and that every
/// field is actually consulted by the merge rather than quietly deferring to the server forever.
/// </para>
/// </remarks>
public sealed class HostGroupSecretTests
{
    private static Guid Parent { get; } = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05");

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

    [Fact]
    public void AFlatGroupWithNoDefaults_RoundTripsAtTheVersionItAlwaysHad()
    {
        var group = Group();

        HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.Group.ShouldBe(group);
        document.SchemaVersion.ShouldBe(HostGroupSecretCodec.BaseSchemaVersion);
        document.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void ANestedGroupCarryingEveryDefault_RoundTrips()
    {
        var group = Group(
            parentId: Parent,
            defaultPort: 2222,
            defaultUsername: "deploy",
            defaultCredentialId: TeamCredential);

        HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.Group.ShouldBe(group);
        document.SchemaVersion.ShouldBe(HostGroupSecretCodec.ParentAndDefaultsSchemaVersion);
        document.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void AddingTheParentAndDefaults_DidNotChangeTheBytesOfAGroupWithoutThem()
    {
        // Pinned against a literal rather than against the codec, because the claim is about history: every
        // group already in every vault must re-encode to what it encoded before any of these fields existed,
        // or the first sync after an upgrade would push every group as changed. Byte-for-byte, so a new
        // field that serialised ahead of the name — or a null that serialised as null — would fail here.
        //
        // The version in this literal is the other half of the claim. This codec used to stamp
        // CurrentSchemaVersion unconditionally, and had that survived, a flat group would now be written at
        // 2 and read as uneditable on every machine that had not upgraded.
        var bytes = HostGroupSecretCodec.Encode(Group());

        Encoding.UTF8.GetString(bytes).ShouldBe("""{"schemaVersion":1,"label":"production"}""");
    }

    [Theory]
    [MemberData(nameof(GroupsCarryingOneNewField))]
    public void AGroupCarryingAnyNewField_IsWrittenAtTheVersionThatIntroducedThem(HostGroupSecret group)
    {
        HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(
            HostGroupSecretCodec.ParentAndDefaultsSchemaVersion,
            "a version that cannot represent the field just written makes an older client decode the "
            + "group as editable and drop that field on the next save");
    }

    public static TheoryData<HostGroupSecret> GroupsCarryingOneNewField() =>
    [
        Group(parentId: Parent),
        Group(defaultPort: 2222),
        Group(defaultUsername: "deploy"),
        Group(defaultSshKeyId: DeployKey),
        Group(defaultCredentialId: TeamCredential),
    ];

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    public void AGroupWithNoName_IsRefused(string label)
    {
        Group(label: label).TryValidate(out var reason).ShouldBeFalse();

        reason.ShouldNotBeNull();
    }

    [Fact]
    public void TryValidate_RejectsWhatCannotBeStored()
    {
        Group(parentId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
        Group(defaultPort: 0).TryValidate(out _).ShouldBeFalse();
        Group(defaultPort: 65536).TryValidate(out _).ShouldBeFalse();
        Group(defaultSshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
        Group(defaultCredentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();

        // Null is the absence of each of these, and the absence is always storable — it is what every group
        // in every vault written before this build carries.
        Group().TryValidate(out _).ShouldBeTrue();
    }

    [Fact]
    public void AGroupDefaultsOneWay_NotTwo()
    {
        // The same exclusion a host is held to, for the same reason: a group naming both leaves "what do
        // hosts under this authenticate with?" without a single answer. Necessary but not sufficient — a
        // host naming a credential under a group naming a key is two valid records, so the resolver enforces
        // it again across the chain.
        var both = Group(defaultSshKeyId: DeployKey, defaultCredentialId: TeamCredential);

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

        Group(defaultSshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
        Group(defaultCredentialId: TeamCredential).TryValidate(out _).ShouldBeTrue();
    }

    [Fact]
    public void AGroupWrittenByANewerClient_IsReadableButNotWritableHere()
    {
        var payload = Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":99,"label":"production","colour":"a field this build has never heard of"}
            """);

        HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();

        document.ShouldNotBeNull();
        document.Group.Label.ShouldBe("production");
        document.IsReadOnly.ShouldBeTrue();
    }

    [Fact]
    public void AnUnnamedPayload_FailsToDecodeRatherThanProducingABlankGroup()
    {
        // Failing closed, as every codec in this folder does: a group with no name is indistinguishable in
        // the sidebar from the ungrouped heading it would sit beside.
        var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":1}""");

        HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse();

        document.ShouldBeNull();
    }

    [Fact]
    public void AnUnstorablePayload_FailsToDecodeRatherThanProducingAGroupThatCannotBeSaved()
    {
        // A default port outside the range cannot have been written by this build, so it is either a bug in
        // some client or a corrupted write. Decoding it would produce a group the editor could open and
        // never save, with nothing on screen to say which field was the problem.
        var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":2,"label":"production","defaultPort":0}""");

        HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse();

        document.ShouldBeNull();
    }

    [Fact]
    public void EveryScalarField_IsRoutedThroughAMerge()
    {
        // A field added to HostGroupSecret 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 = Group();

        var local = ancestor with
        {
            Label = "prod",
            ParentId = Parent,
            DefaultPort = 2222,
            DefaultUsername = "deploy",
            DefaultCredentialId = TeamCredential,
        };

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

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

    [Fact]
    public void TheKeyFieldTheExclusionKeepsOutOfTheOtherTest_IsAlsoRoutedThroughAMerge()
    {
        // DefaultSshKeyId cannot appear beside DefaultCredentialId in one valid group, so it gets its own
        // pass rather than being the one field the guard above silently skips.
        var ancestor = Group();
        var local = ancestor with { DefaultSshKeyId = DeployKey };

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

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

    [Fact]
    public void TwoDifferentRenames_AreReportedWithBothNames()
    {
        var ancestor = Group();

        var result = HostGroupSecretMerge.Merge(
            ancestor,
            ancestor with { Label = "prod" },
            ancestor with { Label = "live" });

        result.Merged.Label.ShouldBe("live");

        var conflict = result.Conflicts.ShouldHaveSingleItem();

        conflict.Field.ShouldBe(nameof(HostGroupSecret.Label));
        conflict.Kept.ShouldBe("live");
        conflict.Discarded.ShouldBe("prod");
    }

    [Fact]
    public void TwoDifferentReparentings_AreReportedWithBothParents()
    {
        // The clash that admits a cycle. This merge sees one group against one group, so it cannot know the
        // pair it is half of — it resolves, reports, and leaves the containment to the resolver's visited
        // set. What it must not do is resolve silently.
        var other = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e07");

        var ancestor = Group();
        var result = HostGroupSecretMerge.Merge(
            ancestor,
            ancestor with { ParentId = Parent },
            ancestor with { ParentId = other });

        result.Merged.ParentId.ShouldBe(other);

        var conflict = result.Conflicts.ShouldHaveSingleItem();

        conflict.Field.ShouldBe(nameof(HostGroupSecret.ParentId));
        conflict.Kept.ShouldBe(other.ToString());
        conflict.Discarded.ShouldBe(Parent.ToString());
    }

    [Fact]
    public void ADefaultClearedLocally_IsNotResurrectedByTheOtherSide()
    {
        // Null is a value here, not an absence: a group deliberately put back to no default user must not
        // silently regain one because the server's copy still names it.
        var ancestor = Group(defaultUsername: "deploy");
        var local = ancestor with { DefaultUsername = null };

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

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

    [Fact]
    public void ADefaultClearedOnOneSideAndChangedOnTheOther_NamesTheAbsenceInTheConflict()
    {
        // The formatter has to run for the null side too. Short-circuiting on null would print an empty
        // string where the conflict log needs to say that what lost was the removal of the default.
        var ancestor = Group(defaultPort: 22);

        var result = HostGroupSecretMerge.Merge(
            ancestor,
            ancestor with { DefaultPort = null },
            ancestor with { DefaultPort = 2222 });

        result.Merged.DefaultPort.ShouldBe(2222);

        var conflict = result.Conflicts.ShouldHaveSingleItem();

        conflict.Field.ShouldBe(nameof(HostGroupSecret.DefaultPort));
        conflict.Kept.ShouldBe("2222");
        conflict.Discarded.ShouldBe("no default port");
    }

    /// <remarks>
    /// The case that would collide if membership were held on the group instead of on each host: two people
    /// filing two different machines into one group at the same time. It cannot reach the merge at all,
    /// because neither of those actions writes to this item.
    /// </remarks>
    [Fact]
    public void FilingHostsIntoAGroup_DoesNotTouchTheGroup()
    {
        var ancestor = Group();

        var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor);

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