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

namespace DodoSSH.Client.Domain.Tests;

/// <summary>
/// The payload encoding.
/// </summary>
/// <remarks>
/// Two properties carry weight here. Determinism, because the sync engine compares to decide whether
/// to push, and a codec that produced different bytes for the same host would make every pass look
/// like a change. And failing closed on anything malformed, because these bytes are decrypted inside
/// a sync pass where an exception would strand every item queued behind the bad one.
/// </remarks>
public sealed class HostSecretCodecTests
{
    /// <remarks>
    /// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
    /// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
    /// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
    /// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
    /// highest schema version a valid host can.
    /// </remarks>
    [Fact]
    public void AFullHost_RoundTrips()
    {
        var credentialId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05");

        var host = Host(
            label: "prod-db",
            hostname: "db.internal",
            port: 2222,
            username: "deploy",
            notes: "primary replica",
            jumps: [Bastion, Relay],
            options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
            relayEnabled: true,
            credentialId: credentialId,
            groupId: Production,
            tags: [Pci, EuWest],
            pinnedPaths: ["/var/www/app", "/var/log"]);

        HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();

        document.ShouldNotBeNull();
        document.Host.ShouldBe(host);
        document.Host.CredentialId.ShouldBe(credentialId);
        document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
        document.IsReadOnly.ShouldBeFalse();
    }

    // ---- The schema version is content-dependent ----

    [Fact]
    public void AHostWithNoKey_IsStillWrittenAtVersionOne()
    {
        // The compatibility rule, and the reason it is worth having. The version is what makes an older
        // client refuse to edit an item, so stamping the newest one on every write would mean upgrading one
        // machine and renaming one host made that host uneditable everywhere else. A host that uses nothing
        // new stays readable and writable by the older build.
        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host()), out var document).ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
    }

    [Fact]
    public void AHostThatBindsAKey_IsWrittenAtTheVersionThatIntroducedIt()
    {
        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion);
        document.Host.SshKeyId.ShouldBe(DeployKey);
    }

    [Fact]
    public void AHostThatBindsACredential_IsWrittenAtTheVersionThatIntroducedIt()
    {
        // Each binding earns its own version, so a host using only the older one is not dragged forward onto
        // a version older clients refuse to edit.
        var credentialId = Guid.CreateVersion7();

        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(credentialId: credentialId)), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.CredentialIdSchemaVersion);
        document.Host.CredentialId.ShouldBe(credentialId);
    }

    [Fact]
    public void AKeyBoundHost_IsNotDraggedOntoTheCredentialVersion()
    {
        // The point of the rule. Adding credentials must not make every key-bound host in every vault
        // read-only on a client that understands keys perfectly well.
        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document)
            .ShouldBeTrue();

        var version = document.ShouldNotBeNull().SchemaVersion;

        version.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion);
        version.ShouldBeLessThan(HostSecretCodec.CredentialIdSchemaVersion);
    }

    [Fact]
    public void AGroupedHost_IsWrittenAtTheVersionThatIntroducedGroups()
    {
        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(groupId: Production)), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion);
        document.Host.GroupId.ShouldBe(Production);
    }

    /// <remarks>
    /// <para>
    /// The case that turned the version rule from a ladder into a maximum, and the one that would have shipped
    /// a real defect. A group is orthogonal to the two authentication bindings — a host may carry a credential
    /// and a group at once — so a <c>switch</c> returning the first match would have answered 3 for this host:
    /// a version that has no concept of the group the same write just stored.
    /// </para>
    /// <para>
    /// What that produces is worse than a wrong number. An older client reads schema 3, concludes the payload
    /// holds nothing it does not understand, offers to edit the host, and drops the group on save — silently,
    /// on every machine that has not been upgraded. Asserted for both bindings, because the ladder's order
    /// meant only one of the two would have been caught by a single case.
    /// </para>
    /// </remarks>
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void AHostThatIsBothBoundAndGrouped_IsWrittenAtTheHigherOfTheTwo(bool byCredential)
    {
        var host = byCredential
            ? Host(credentialId: Guid.CreateVersion7(), groupId: Production)
            : Host(sshKeyId: DeployKey, groupId: Production);

        HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion);
        document.Host.ShouldBe(host);
    }

    [Fact]
    public void AHostThatInheritsItsPort_IsWrittenAtTheVersionThatIntroducedInheritance()
    {
        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(
            HostSecretCodec.PortInheritSchemaVersion,
            "a host stamped at 4 with its port omitted is not read-only on an older build — it is "
            + "undecodable there, because int Port reads 0 and TryValidate refuses it");

        document.Host.Port.ShouldBeNull();
    }

    [Fact]
    public void AHostPinnedToATypedPassword_IsWrittenAtTheSameVersionAsAnInheritedPort()
    {
        // Both halves of inheritance share a version because they arrive together and answer the same
        // question: what a host says when it declines to take its group's answer.
        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(asksForPassword: true)), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.PortInheritSchemaVersion);
        document.Host.AsksForPassword.ShouldBe(true);
    }

    [Fact]
    public void AHostWearingTags_IsWrittenAtTheVersionThatIntroducedThem()
    {
        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.TagIdsSchemaVersion);
        document.Host.TagIds.ShouldBe(TagSet.Create([Pci]));
    }

    [Fact]
    public void ATaggedHostThatPinsItsPort_IsNotDraggedOntoTheInheritanceVersionOrBelowIt()
    {
        // The maximum, from both directions. Tags are independent of inheritance, so a host that only wears
        // one must not be written at 5 — and a host that only inherits must not be written at 6, which would
        // make it undecodable on a build that could have read it.
        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var tagged)
            .ShouldBeTrue();

        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var inheriting)
            .ShouldBeTrue();

        tagged.ShouldNotBeNull().SchemaVersion
            .ShouldBeGreaterThan(HostSecretCodec.PortInheritSchemaVersion);

        inheriting.ShouldNotBeNull().SchemaVersion
            .ShouldBeLessThan(HostSecretCodec.TagIdsSchemaVersion);
    }

    [Fact]
    public void AHostWithAnEmptyTagSet_IsWrittenAtTheVersionItWouldHaveHadWithout()
    {
        // Wearing no tags is not using the feature. If an empty set bumped the version, adding tags would
        // have made every host in every vault read-only on every machine that had not upgraded.
        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [])), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
    }

    [Fact]
    public void AHostPinningPaths_IsWrittenAtTheVersionThatIntroducedThem()
    {
        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: ["/var/www/app"])), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
        document.Host.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app"]));
    }

    [Fact]
    public void AHostWithNoPinnedPaths_IsWrittenAtTheVersionItWouldHaveHadWithout()
    {
        // Pinning nothing is not using the feature, for the same reason an empty tag set is not: bumping
        // the version for it would have made every host in every vault read-only on every machine that had
        // not upgraded yet.
        HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: [])), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
    }

    [Fact]
    public void ATaggedHostThatAlsoPinsPaths_IsWrittenAtTheHigherOfTheTwoVersions()
    {
        // Independent fields, so the version is a maximum over both rather than whichever this switch
        // happened to check last — the same defect the group-and-binding case guards against above.
        HostSecretCodec
            .TryDecode(HostSecretCodec.Encode(Host(tags: [Pci], pinnedPaths: ["/var/www/app"])), out var document)
            .ShouldBeTrue();

        document.ShouldNotBeNull();
        document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
    }

    [Fact]
    public void AHostPinningPaths_WritesThemLastAndInListOrder()
    {
        // Last, so every field that existed before them keeps its bytes; in list order rather than sorted,
        // unlike tags — order is the meaning of a pinned-path list, so canonicalising it away would lose
        // exactly what the feature is for.
        var bytes = HostSecretCodec.Encode(
            Host(username: null, notes: null, pinnedPaths: ["/var/www/app", "/var/log"]));

        Encoding.UTF8.GetString(bytes).ShouldBe(
            """
            {"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"pinnedPaths":["/var/www/app","/var/log"]}
            """);
    }

    [Fact]
    public void APayloadWithoutPinnedPaths_DecodesAsPinningNothing()
    {
        // The compatibility case this field exists to pass: a payload written before pinned paths existed
        // must read cleanly as a host that pins nothing, not fail to decode.
        var payload = Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08"]}
            """);

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

        document.ShouldNotBeNull().Host.PinnedPaths.ShouldBe(PinnedPathList.Empty);
    }

    [Fact]
    public void APayloadRepeatingAPinnedPath_DecodesKeepingTheFirstOccurrence()
    {
        // Written by some other client, and it must not compare unequal to the same list written once —
        // or the engine would push this host as changed on every pass for ever.
        var payload = Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/www/app","/var/log","/var/www/app"]}
            """);

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

        document.ShouldNotBeNull().Host.PinnedPaths
            .ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
    }

    [Fact]
    public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither()
    {
        // The companion to the pin below, and the one that matters most for these two fields: a set that
        // serialised as [] and a flag that serialised as false would both land in every host in every vault,
        // and the first sync after the upgrade would push all of them as changed.
        var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, tags: []));

        Encoding.UTF8.GetString(bytes).ShouldBe(
            """
            {"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
            """);
    }

    [Fact]
    public void AHostThatInheritsItsPort_OmitsTheKeyRatherThanWritingANull()
    {
        // The companion pin the old one could not be edited into. What an inheriting host must produce is
        // the absence of "port", not "port":null and not "port":22 — the first would decode as 0 on any
        // build, and the second is what inheritance exists to stop writing.
        var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, port: null));

        Encoding.UTF8.GetString(bytes).ShouldBe(
            """
            {"schemaVersion":5,"label":"prod-db","hostname":"db.internal","jumpHostIds":[],"options":{},"relayEnabled":false}
            """);
    }

    [Fact]
    public void AHostWearingTags_WritesThemLastAndSorted()
    {
        // Last, so every field that existed before them keeps its bytes; sorted, so two users who tapped the
        // same two chips in opposite orders produce one value and nothing to push.
        var bytes = HostSecretCodec.Encode(
            Host(username: null, notes: null, tags: [EuWest, Pci]));

        Encoding.UTF8.GetString(bytes).ShouldBe(
            """
            {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08","0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09"]}
            """);
    }

    [Fact]
    public void APayloadSayingItAsksForNoPassword_DecodesAsHavingSaidNothing()
    {
        // False and null mean the same thing — take the group's binding — so only one may reach the record.
        // Two spellings of one state is a difference the merge would report as a change nobody made.
        var payload = Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":5,"label":"prod-db","hostname":"db.internal","port":22,"asksForPassword":false}
            """);

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

        document.ShouldNotBeNull().Host.AsksForPassword.ShouldBeNull();
    }

    [Fact]
    public void APayloadRepeatingATag_DecodesAsTheSetItMeant()
    {
        // Written by some other client, and it must not compare unequal to the same set written once — or
        // the engine would push this host as changed on every pass for ever.
        var payload = Encoding.UTF8.GetBytes(
            $$"""
            {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["{{Pci}}","{{Pci}}"]}
            """);

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

        document.ShouldNotBeNull().Host.TagIds.ShouldBe(TagSet.Create([Pci]));
    }

    [Fact]
    public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne()
    {
        // Pinned against a literal rather than against the codec, because the claim is about history: every
        // host already in every vault must re-encode to what it encoded before SshKeyId existed, or the
        // first sync after an upgrade would push the entire vault as changed. Byte-for-byte, so a new field
        // that serialised ahead of these — or a null that serialised as null — would fail here.
        var bytes = HostSecretCodec.Encode(Host(username: null, notes: null));

        Encoding.UTF8.GetString(bytes).ShouldBe(
            """
            {"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
            """);
    }

    [Fact]
    public void AHostBoundToAKeyByANewerClient_IsReadableButNotWritableHere()
    {
        // What an older build sees. Simulated by a version past this one rather than by an older codec,
        // since the mechanism is the comparison and not the field: read the item, refuse to re-encode it.
        var payload = Encoding.UTF8.GetBytes(
            """
            {"schemaVersion":99,"label":"prod-db","hostname":"db.internal","port":22,"certificateId":"something this build has never heard of"}
            """);

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

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

    [Fact]
    public void AMinimalHost_RoundTrips()
    {
        var host = Host(username: null, notes: null);

        HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();

        document!.Host.ShouldBe(host);
        document.Host.Username.ShouldBeNull();
        document.Host.Notes.ShouldBeNull();
    }

    [Fact]
    public void Encoding_IsDeterministic()
    {
        var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);

        HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host));
    }

    [Fact]
    public void DirectiveOrder_DoesNotAffectTheEncoding()
    {
        // Two clients that agree on the content must produce the same bytes regardless of the order
        // the user happened to type the directives in.
        var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
        var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);

        HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other));
    }

    [Fact]
    public void APayloadFromANewerSchema_IsReadableButReadOnly()
    {
        // The forward-compatibility rule. An old client can show the host but must not re-encode it,
        // because it has no representation for the newer client's extra fields and would drop them.
        var payload = Json("""
            {
              "schemaVersion": 99,
              "label": "prod-db",
              "hostname": "db.internal",
              "port": 22,
              "unknownFutureField": { "nested": true }
            }
            """);

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

        document!.Host.Label.ShouldBe("prod-db");
        document.Host.Hostname.ShouldBe("db.internal");
        document.IsReadOnly.ShouldBeTrue();
    }

    [Fact]
    public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal()
    {
        var payload = Json("""
            {
              "schemaVersion": 1,
              "label": "prod-db",
              "hostname": "db.internal",
              "port": 22,
              "somethingElse": 5
            }
            """);

        HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
        document!.IsReadOnly.ShouldBeFalse();
    }

    [Theory]
    [InlineData("")]
    [InlineData("not json at all")]
    [InlineData("{")]
    [InlineData("[]")]
    [InlineData("null")]
    public void MalformedBytes_ReturnFalseRatherThanThrow(string text)
    {
        HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse();
        document.ShouldBeNull();
    }

    [Theory]
    [InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")]
    [InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")]
    [InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")]
    [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")]
    [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")]
    [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")]
    public void AStructurallyInvalidPayload_IsRejected(string json)
    {
        HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse();
    }

    [Fact]
    public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected()
    {
        // Fails closed. SSH treats keywords case-insensitively, so this payload has no single
        // meaning; guessing which one wins would make two clients disagree about the same bytes.
        var payload = Json("""
            {
              "schemaVersion": 1,
              "label": "prod-db",
              "hostname": "db.internal",
              "port": 22,
              "options": { "Compression": "yes", "compression": "no" }
            }
            """);

        HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
    }

    [Fact]
    public void AnEmptyJumpHostId_IsRejected()
    {
        var payload = Json($$"""
            {
              "schemaVersion": 1,
              "label": "prod-db",
              "hostname": "db.internal",
              "port": 22,
              "jumpHostIds": ["{{Guid.Empty}}"]
            }
            """);

        HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
    }

    [Fact]
    public void Encode_RefusesAnInvalidHost()
    {
        // Throwing rather than returning false, because unlike decoding, this is a caller bug: the
        // host came from this process and should have been validated before it got here.
        Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(label: "  ")));
        Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(port: 0)));
    }

    [Fact]
    public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope()
    {
        // A reminder of what this codec is for: every one of these values is inside the ciphertext.
        // There is no plaintext host label anywhere in the system.
        var host = Host(label: "prod-db", notes: "root password in 1Password");

        var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host));

        text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue();
        text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue();
    }

    private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text);
}
