namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// The SSH key record, its codec and its merge.
/// </summary>
/// <remarks>
/// The codec is the point at which a private key becomes bytes and comes back, so a bug here is a key that
/// either does not survive a round trip or survives it in a form SSH.NET will not load. The sync suite
/// exercises all of this through two devices and a server, which is the right place for the reconciliation
/// rules — but it cannot say which of these types was wrong when it fails.
/// </remarks>
public sealed class SshKeySecretTests
{
    private const string Material =
        "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEA\n-----END OPENSSH PRIVATE KEY-----\n";

    // ---- The record ----

    [Fact]
    public void AnEmptyPassphrase_IsTheSameAsNone()
    {
        // One spelling of one state. The two that follow are what it buys: identical keys encode
        // identically, so they cannot produce a spurious merge conflict, and "is this key protected?" has a
        // single reliable answer for the interface to read.
        Key(passphrase: string.Empty).Passphrase.ShouldBeNull();
        Key(passphrase: null).Passphrase.ShouldBeNull();
        Key(passphrase: "hunter2").Passphrase.ShouldBe("hunter2");
    }

    [Fact]
    public void APassphraseOfSpaces_IsKept()
    {
        // Whitespace is a legal passphrase, so this is deliberately not IsNullOrWhiteSpace. Trimming it
        // would silently change the passphrase of a key someone can still open elsewhere.
        Key(passphrase: "  ").Passphrase.ShouldBe("  ");
    }

    [Fact]
    public void AnEmptyPassphraseAndNone_AreEqual()
    {
        // Follows from the normalisation, and it is the property the merge depends on: it compares the two
        // sides for equality to decide whether anything changed at all.
        Key(passphrase: string.Empty).ShouldBe(Key(passphrase: null));
    }

    [Theory]
    [InlineData("", Material, "needs a name")]
    [InlineData("   ", Material, "needs a name")]
    [InlineData("deploy", "", "private key material")]
    [InlineData("deploy", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop", ".pub")]
    [InlineData("deploy", "ecdsa-sha2-nistp256 AAAAE2VjZHNh deploy@laptop", ".pub")]
    [InlineData("deploy", "not a key at all", "-----BEGIN")]
    public void AnInvalidKey_SaysWhatIsWrongWithIt(string label, string material, string expected)
    {
        var key = new SshKeySecret { Label = label, PrivateKeyPem = material };

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

    [Fact]
    public void AKeyWithLeadingWhitespace_IsStillRecognised()
    {
        // A paste out of a terminal or an editor arrives with a newline in front of it more often than not.
        var key = new SshKeySecret { Label = "deploy", PrivateKeyPem = "\n  " + Material };

        key.TryValidate(out var reason).ShouldBeTrue(reason);
    }

    // ---- The codec ----

    [Fact]
    public void AKey_SurvivesARoundTrip()
    {
        var key = Key(passphrase: "hunter2") with
        {
            PublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop",
            Notes = "rotate in June",
        };

        var encoded = SshKeySecretCodec.Encode(key);

        SshKeySecretCodec.TryDecode(encoded, out var document).ShouldBeTrue();
        document.ShouldNotBeNull();
        document.Key.ShouldBe(key);
        document.SchemaVersion.ShouldBe(SshKeySecretCodec.CurrentSchemaVersion);
        document.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void TheMaterialIsNotReformatted()
    {
        // Verbatim, including the trailing newline. OpenSSH, PKCS#1 and PKCS#8 all round-trip untouched
        // because nothing here parses them, and a client that normalised the armour would eventually
        // normalise a format it did not fully understand.
        var awkward = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIBOgIBAAJB\r\n-----END RSA PRIVATE KEY-----";

        var encoded = SshKeySecretCodec.Encode(Key() with { PrivateKeyPem = awkward });

        SshKeySecretCodec.TryDecode(encoded, out var document).ShouldBeTrue();
        document.ShouldNotBeNull().Key.PrivateKeyPem.ShouldBe(awkward);
    }

    [Fact]
    public void EncodingIsDeterministic()
    {
        // An unchanged key must not look like a change to the sync engine, which compares ciphertext-bearing
        // payloads derived from these bytes.
        SshKeySecretCodec.Encode(Key(passphrase: "hunter2"))
            .ShouldBe(SshKeySecretCodec.Encode(Key(passphrase: "hunter2")));
    }

    [Fact]
    public void AnEmptyPassphraseIsNotWrittenAtAll()
    {
        // The normalisation reaches the wire: a key saved with a blank box is byte-identical to one saved
        // with no passphrase, so the two cannot diverge into a spurious conflict on another machine.
        SshKeySecretCodec.Encode(Key(passphrase: string.Empty))
            .ShouldBe(SshKeySecretCodec.Encode(Key(passphrase: null)));
    }

    [Fact]
    public void AnEmptyPassphraseWrittenByAnotherClient_DecodesAsNone()
    {
        var payload = System.Text.Encoding.UTF8.GetBytes(
            $$"""
            {"schemaVersion":1,"label":"deploy","privateKeyPem":{{System.Text.Json.JsonSerializer.Serialize(Material)}},"passphrase":""}
            """);

        SshKeySecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
        document.ShouldNotBeNull().Key.Passphrase.ShouldBeNull();
    }

    [Theory]
    [InlineData("not json at all")]
    [InlineData("{}")]
    [InlineData("""{"schemaVersion":0,"label":"deploy","privateKeyPem":"x"}""")]
    [InlineData("""{"schemaVersion":1,"label":"deploy"}""")]
    [InlineData("""{"schemaVersion":1,"privateKeyPem":"-----BEGIN X-----"}""")]
    public void APayloadThatIsNotAKey_DoesNotDecode(string json)
    {
        // False rather than a throw, and rather than a half-built key. A decode failure is what a rotated
        // vault key and a server handing back the wrong bytes both look like from here, and neither must
        // abort a sync pass.
        SshKeySecretCodec
            .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document)
            .ShouldBeFalse();

        document.ShouldBeNull();
    }

    [Fact]
    public void AKeyFromANewerClient_IsReadableButNotWritable()
    {
        var payload = System.Text.Encoding.UTF8.GetBytes(
            $$"""
            {"schemaVersion":99,"label":"deploy","privateKeyPem":{{System.Text.Json.JsonSerializer.Serialize(Material)}},"certificate":"something this build has never heard of"}
            """);

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

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(99);
        document.IsReadOnly.ShouldBeTrue(
            "re-encoding would drop the field, leaving a key that still decrypts and no longer works");
    }

    // ---- The merge ----

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

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

        merged.HasConflicts.ShouldBeFalse();
        merged.Merged.Label.ShouldBe("deploy-laptop");
        merged.Merged.Notes.ShouldBe("from the desktop");
        merged.Merged.PrivateKeyPem.ShouldBe(ancestor.PrivateKeyPem);
    }

    [Fact]
    public void BothSidesReplacingTheMaterial_ReportsTheClashWithoutQuotingEitherKey()
    {
        var ancestor = Key();
        var local = ancestor with { PrivateKeyPem = Armour("LAPTOP-SECRET") };
        var remote = ancestor with { PrivateKeyPem = Armour("DESKTOP-SECRET") };

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

        merged.HasConflicts.ShouldBeTrue();

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(SshKeySecret.PrivateKeyPem));

        // Named, so the user knows what clashed. Not quoted, because the conflict log is stored to be read
        // and is deliberately kept after acknowledgement.
        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");
        }

        // And the surviving key is a real one — redacting the report must not redact the value.
        merged.Merged.PrivateKeyPem.ShouldBeOneOf(local.PrivateKeyPem, remote.PrivateKeyPem);
    }

    [Fact]
    public void BothSidesChangingThePassphrase_IsAlsoRedacted()
    {
        var ancestor = Key(passphrase: "original");
        var local = ancestor with { Passphrase = "laptop-passphrase" };
        var remote = ancestor with { Passphrase = "desktop-passphrase" };

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

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(SshKeySecret.Passphrase));

        var kept = conflict.Kept.ShouldNotBeNull();
        kept.ShouldNotContain("passphrase-");
        kept.ShouldNotContain("laptop-passphrase");

        conflict.Discarded.ShouldNotBeNull().ShouldNotContain("desktop-passphrase");
    }

    [Fact]
    public void ALabelClash_IsShownInFull()
    {
        // The counterpart to the redaction: a label is not a secret, and hiding it would leave the user
        // unable to tell which name was discarded.
        var ancestor = Key();
        var local = ancestor with { Label = "deploy-laptop" };
        var remote = ancestor with { Label = "deploy-desktop" };

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

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(SshKeySecret.Label));

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

    [Fact]
    public void BothSidesMakingTheSameEdit_IsNotAConflict()
    {
        var ancestor = Key();
        var edited = ancestor with { Notes = "rotate in June" };

        var merged = SshKeySecretMerge.Merge(ancestor, edited, edited);

        merged.HasConflicts.ShouldBeFalse();
        merged.Merged.ShouldBe(edited);
    }

    private static string Armour(string body) =>
        $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";

    private static SshKeySecret Key(string? passphrase = null) =>
        new() { Label = "deploy", PrivateKeyPem = Material, Passphrase = passphrase };
}
