namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// The known-host record, its codec and its merge.
/// </summary>
/// <remarks>
/// Shorter again than <see cref="CredentialSecretTests"/>, because three of the four fields are what the item
/// is <em>about</em> rather than content that gets edited. What is specific to this type and worth pinning:
/// that its name is derived rather than stored, that the derived name stays out of the payload and out of
/// equality, that a mangled fingerprint is refused rather than stored to fail comparisons for ever, and that a
/// fingerprint clash is reported with both values — the opposite of what the password merge does.
/// </remarks>
public sealed class KnownHostSecretTests
{
    [Fact]
    public void APinIsNamedAfterWhatItPins()
    {
        // Read by the conflict log, which is the only place a person meets one of these. "db.internal:22" is
        // something they can match against a host in their list; an item id is not.
        Pin().Label.ShouldBe("db.internal:22 (ssh-ed25519)");
        Pin(port: 2222).Label.ShouldBe("db.internal:2222 (ssh-ed25519)");
    }

    [Fact]
    public void TwoPinsOfTheSameKey_AreEqual()
    {
        // The derived label is get-only, so it stays out of the record's equality — which is what makes
        // "these are the same pin" a question about the host, port, algorithm and fingerprint alone. The
        // reconciler compares secrets this way to recognise its own create coming back.
        Pin().ShouldBe(Pin());
        Pin(fingerprint: "SHA256:something-else").ShouldNotBe(Pin());
    }

    [Theory]
    [InlineData("", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")]
    [InlineData("   ", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")]
    [InlineData("db.internal", 0, "ssh-ed25519", "SHA256:aaa", "Port must be between")]
    [InlineData("db.internal", 65536, "ssh-ed25519", "SHA256:aaa", "Port must be between")]
    [InlineData("db.internal", 22, "", "SHA256:aaa", "needs the key algorithm")]
    [InlineData("db.internal", 22, "ssh ed25519", "SHA256:aaa", "needs the key algorithm")]
    [InlineData("db.internal", 22, "ssh-ed25519", "", "needs a fingerprint")]
    [InlineData("db.internal", 22, "ssh-ed25519", "SHA256:aaa bbb", "needs a fingerprint")]
    public void AnInvalidPin_SaysWhatIsWrongWithIt(
        string host,
        int port,
        string algorithm,
        string fingerprint,
        string expected)
    {
        var pin = new KnownHostSecret
        {
            Host = host,
            Port = port,
            Algorithm = algorithm,
            Fingerprint = fingerprint,
        };

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

    [Fact]
    public void APinWithSpaceInItsFingerprint_IsRefusedRatherThanStored()
    {
        // A pasted "SHA256:… comment@host" or a stray newline would compare unequal to the same key on every
        // future connection, which the user would read as a permanently changed host key. Refusing it at the
        // codec means the bad value never becomes a stored pin.
        var mangled = Pin(fingerprint: "SHA256:aaa bbb");

        Should.Throw<ArgumentException>(() => KnownHostSecretCodec.Encode(mangled));
    }

    [Fact]
    public void APin_SurvivesARoundTrip()
    {
        var pin = Pin(host: "bastion.internal", port: 2222, algorithm: "rsa-sha2-512");

        var encoded = KnownHostSecretCodec.Encode(pin);

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

        document.ShouldNotBeNull();
        document.KnownHost.ShouldBe(pin);
        document.SchemaVersion.ShouldBe(KnownHostSecretCodec.CurrentSchemaVersion);
        document.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void EncodingIsDeterministic()
    {
        // An unchanged pin must not look like a change to the sync engine, or every pass would push every
        // host the user has ever approved.
        KnownHostSecretCodec.Encode(Pin()).ShouldBe(KnownHostSecretCodec.Encode(Pin()));
    }

    [Fact]
    public void TheDerivedLabel_IsNotInThePayload()
    {
        // It is a function of the three fields that are, so writing it would put a value on the wire that a
        // reader could disagree with — and a merge could then take the label from one side and the address
        // from the other.
        var json = System.Text.Encoding.UTF8.GetString(KnownHostSecretCodec.Encode(Pin()));

        json.ShouldNotContain("label");
        json.ShouldNotContain("(ssh-ed25519)");
    }

    [Theory]
    [InlineData("not json")]
    [InlineData("{}")]
    [InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"algorithm":"ssh-ed25519"}""")]
    [InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"fingerprint":"SHA256:aaa"}""")]
    [InlineData("""{"schemaVersion":1,"port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")]
    [InlineData(
        """{"schemaVersion":1,"host":"db.internal","algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")]
    [InlineData(
        """{"schemaVersion":0,"host":"db.internal","port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:a"}""")]
    public void APayloadThatIsNotAPin_DoesNotDecode(string json)
    {
        KnownHostSecretCodec
            .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document)
            .ShouldBeFalse();

        document.ShouldBeNull();
    }

    [Fact]
    public void APinFromANewerClient_IsReadableButNotWritable()
    {
        // Readable matters here more than for the other types: an unreadable pin means a host looks unvisited
        // and the user is asked again. The four fields a pin needs are all present, so a newer schema is
        // usable for comparison even though this build must not re-encode it.
        var payload = System.Text.Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":99,"host":"db.internal","port":22,"algorithm":"ssh-ed25519",
             "fingerprint":"SHA256:aaa","approvedBy":"someone using a later build"}
            """);

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

        document.ShouldNotBeNull();
        document.IsReadOnly.ShouldBeTrue();
        document.KnownHost.Fingerprint.ShouldBe("SHA256:aaa");
    }

    [Fact]
    public void OnlyOneSideReApproving_TakesThatSide()
    {
        var ancestor = Pin();
        var local = ancestor with { Fingerprint = "SHA256:the-rebuilt-server" };

        var merged = KnownHostSecretMerge.Merge(ancestor, local, ancestor);

        merged.HasConflicts.ShouldBeFalse();
        merged.Merged.Fingerprint.ShouldBe("SHA256:the-rebuilt-server");
    }

    [Fact]
    public void BothSidesApprovingADifferentKey_ReportsBothFingerprints()
    {
        // Deliberately the opposite of the password merge. An operator publishes a fingerprint so that it can
        // be compared, and a notice that withheld the value it dropped would leave the user nothing to check.
        var ancestor = Pin();
        var local = ancestor with { Fingerprint = "SHA256:seen-from-the-laptop" };
        var remote = ancestor with { Fingerprint = "SHA256:seen-from-the-desktop" };

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

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(KnownHostSecret.Fingerprint));

        conflict.Kept.ShouldBe("SHA256:seen-from-the-desktop");
        conflict.Discarded.ShouldBe("SHA256:seen-from-the-laptop");

        // The server's value wins, as it must for every replica to converge on the same answer.
        merged.Merged.Fingerprint.ShouldBe("SHA256:seen-from-the-desktop");
    }

    [Fact]
    public void APortClash_IsReportedAsANumberRatherThanAsNothing()
    {
        // Not reachable from this client — the store never re-addresses a pin — but a payload from elsewhere
        // is untrusted input, and a conflict entry with an empty value in it would be a notice about nothing.
        var ancestor = Pin();
        var local = ancestor with { Port = 2222 };
        var remote = ancestor with { Port = 2022 };

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

        var conflict = merged.Conflicts.ShouldHaveSingleItem();
        conflict.Field.ShouldBe(nameof(KnownHostSecret.Port));
        conflict.Kept.ShouldBe("2022");
        conflict.Discarded.ShouldBe("2222");
    }

    private static KnownHostSecret Pin(
        string host = "db.internal",
        int port = 22,
        string algorithm = "ssh-ed25519",
        string fingerprint = "SHA256:aaa") =>
        new()
        {
            Host = host,
            Port = port,
            Algorithm = algorithm,
            Fingerprint = fingerprint,
        };
}
