namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// The credential record, its codec and its merge.
/// </summary>
/// <remarks>
/// Deliberately shorter than <see cref="SshKeySecretTests"/>. The two types have the same shape and the same
/// merge strategy, so what is covered here is what is specific to a credential: that an empty password is
/// refused while a password of spaces is not, that "no username" has one spelling, and that the password
/// never appears in a conflict log.
/// </remarks>
public sealed class CredentialSecretTests
{
    [Fact]
    public void AnEmptyUsername_IsTheSameAsNone()
    {
        // One spelling of "use the host's username", for the same reasons SshKeySecret.Passphrase normalises:
        // identical credentials encode identically, and "does this override the host?" has one answer.
        Credential(username: string.Empty).Username.ShouldBeNull();
        Credential(username: null).Username.ShouldBeNull();
        Credential(username: "postgres").Username.ShouldBe("postgres");

        Credential(username: string.Empty).ShouldBe(Credential(username: null));
    }

    [Fact]
    public void APasswordOfSpaces_IsAPassword()
    {
        // IsNullOrWhiteSpace would refuse this, and refusing it would lock someone out of a host over a
        // validation opinion. Only genuinely empty is refused.
        Credential(password: "   ").TryValidate(out var reason).ShouldBeTrue(reason);
        Credential(password: string.Empty).TryValidate(out _).ShouldBeFalse();
    }

    [Theory]
    [InlineData("", "hunter2", "needs a name")]
    [InlineData("   ", "hunter2", "needs a name")]
    [InlineData("db", "", "needs a password")]
    public void AnInvalidCredential_SaysWhatIsWrongWithIt(string label, string password, string expected)
    {
        var credential = new CredentialSecret { Label = label, Password = password };

        credential.TryValidate(out var reason).ShouldBeFalse();
        reason.ShouldNotBeNull().ShouldContain(expected);
    }

    [Fact]
    public void ACredential_SurvivesARoundTrip()
    {
        var credential = Credential(username: "postgres") with { Notes = "rotate in June" };

        var encoded = CredentialSecretCodec.Encode(credential);

        CredentialSecretCodec.TryDecode(encoded, out var document).ShouldBeTrue();

        document.ShouldNotBeNull();
        document.Credential.ShouldBe(credential);
        document.SchemaVersion.ShouldBe(CredentialSecretCodec.CurrentSchemaVersion);
        document.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void EncodingIsDeterministic()
    {
        CredentialSecretCodec.Encode(Credential())
            .ShouldBe(CredentialSecretCodec.Encode(Credential()));
    }

    [Fact]
    public void AnEmptyUsernameIsNotWrittenAtAll()
    {
        CredentialSecretCodec.Encode(Credential(username: string.Empty))
            .ShouldBe(CredentialSecretCodec.Encode(Credential(username: null)));
    }

    [Theory]
    [InlineData("not json")]
    [InlineData("{}")]
    [InlineData("""{"schemaVersion":1,"label":"db"}""")]
    [InlineData("""{"schemaVersion":1,"password":"hunter2"}""")]
    [InlineData("""{"schemaVersion":0,"label":"db","password":"hunter2"}""")]
    public void APayloadThatIsNotACredential_DoesNotDecode(string json)
    {
        CredentialSecretCodec
            .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document)
            .ShouldBeFalse();

        document.ShouldBeNull();
    }

    [Fact]
    public void ACredentialFromANewerClient_IsReadableButNotWritable()
    {
        var payload = System.Text.Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":99,"label":"db","password":"hunter2","totpSeed":"something new"}
            """);

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

        document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue();
    }

    [Fact]
    public void EachSideEditingADifferentField_KeepsBoth()
    {
        var ancestor = Credential();
        var local = ancestor with { Label = "db-primary" };
        var remote = ancestor with { Notes = "from the desktop" };

        var merged = CredentialSecretMerge.Merge(ancestor, local, remote);

        merged.HasConflicts.ShouldBeFalse();
        merged.Merged.Label.ShouldBe("db-primary");
        merged.Merged.Notes.ShouldBe("from the desktop");
        merged.Merged.Password.ShouldBe(ancestor.Password);
    }

    [Fact]
    public void BothSidesChangingThePassword_NeverPutsEitherInTheConflictLog()
    {
        // The reason CredentialSecretMerge redacts, and the case for it is if anything plainer than for a
        // key: a discarded password is very often still the live password on some other system.
        var ancestor = Credential();
        var local = ancestor with { Password = "LAPTOP-SECRET" };
        var remote = ancestor with { Password = "DESKTOP-SECRET" };

        var merged = CredentialSecretMerge.Merge(ancestor, local, remote);

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(CredentialSecret.Password));

        var kept = conflict.Kept.ShouldNotBeNull();
        var discarded = conflict.Discarded.ShouldNotBeNull();

        foreach (var reported in new[] { kept, discarded })
        {
            reported.ShouldNotContain("LAPTOP-SECRET");
            reported.ShouldNotContain("DESKTOP-SECRET");
        }

        // Redacting the report must not redact the value.
        merged.Merged.Password.ShouldBeOneOf("LAPTOP-SECRET", "DESKTOP-SECRET");
    }

    [Fact]
    public void AUsernameClash_IsShownInFull()
    {
        // Not a secret, and knowing which account the merge dropped is the whole use of the notice.
        var ancestor = Credential();
        var local = ancestor with { Username = "postgres" };
        var remote = ancestor with { Username = "deploy" };

        var merged = CredentialSecretMerge.Merge(ancestor, local, remote);

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(CredentialSecret.Username));

        new[] { conflict.Kept, conflict.Discarded }.ShouldBe(["deploy", "postgres"], ignoreOrder: true);
    }

    [Fact]
    public void AUsernameClashingWithItsRemoval_SaysWhichSideHadNone()
    {
        var ancestor = Credential(username: "root");
        var local = ancestor with { Username = null };
        var remote = ancestor with { Username = "deploy" };

        var merged = CredentialSecretMerge.Merge(ancestor, local, remote);

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Kept.ShouldBe("deploy");
        conflict.Discarded.ShouldBe("(none)");
    }

    private static CredentialSecret Credential(
        string password = "hunter2",
        string? username = null) =>
        new() { Label = "db", Password = password, Username = username };
}
