using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Sync.Tests;

/// <summary>
/// Sealing and opening a host payload.
/// </summary>
/// <remarks>
/// Mostly negative tests, and deliberately so. docs/crypto.md §4.4 claims a server holding every
/// ciphertext still cannot move a payload between rows, roll one back to an earlier generation, or pair
/// one item's envelope with another's key wrap. Those claims are only worth making if something checks
/// them at the layer that actually assembles the AAD.
/// </remarks>
public sealed class HostCipherTests
{
    private static readonly Guid HostA = Guid.Parse("0192f0c8-000a-7c3d-8e4f-5a6b7c8d9e0f");
    private static readonly Guid HostB = Guid.Parse("0192f0c8-000b-7c3d-8e4f-5a6b7c8d9e0f");

    private readonly byte[] vaultKey = VaultKeys.Create();
    private readonly byte[] otherVaultKey = VaultKeys.Create();

    [Fact]
    public void AHost_RoundTrips()
    {
        var host = Host();
        var payload = HostCipher.Seal(host, vaultKey, HostA, keyGeneration: 1, itemVersion: 1);

        var opened = HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1);

        opened.ShouldNotBeNull();
        opened.Host.ShouldBe(host);
        opened.IsReadOnly.ShouldBeFalse();
    }

    [Fact]
    public void EverySeal_UsesAFreshDataKey()
    {
        // One key per item version, so nonce-collision analysis is moot and a rotation re-wraps 32 bytes
        // rather than rewriting content.
        var host = Host();

        var first = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
        var second = HostCipher.Seal(host, vaultKey, HostA, 1, 1);

        first.DataKeyId.ShouldNotBe(second.DataKeyId);
        first.WrappedDataKey.ShouldNotBe(second.WrappedDataKey);
        first.Envelope.ShouldNotBe(second.Envelope);
    }

    [Fact]
    public void APayload_CannotBeReadAsAnotherItem()
    {
        // The property that stops a server pasting one host's payload onto another row.
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);

        HostCipher.TryOpen(payload, vaultKey, HostB, itemVersion: 1).ShouldBeNull();
    }

    [Fact]
    public void APayload_CannotBeReadAtAnotherVersion()
    {
        // The sharpest edge in this layer. A payload is sealed at the version the server will assign, so
        // getting that prediction wrong produces something that encrypts cleanly and never decrypts. The
        // binding is what turns a silent corruption into a visible failure.
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 1, itemVersion: 2);

        HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
        HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 3).ShouldBeNull();
        HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 2).ShouldNotBeNull();
    }

    [Fact]
    public void APayload_CannotBeRolledBackToAnEarlierKeyGeneration()
    {
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 2, itemVersion: 1);

        // The generation travels with the payload, so a server rewriting the column to 1 changes the AAD
        // the client recomputes and the tag fails.
        var rolledBack = payload with { KeyGeneration = 1 };

        HostCipher.TryOpen(rolledBack, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
    }

    [Fact]
    public void APayload_CannotBeReadWithAnotherVaultsKey()
    {
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);

        HostCipher.TryOpen(payload, otherVaultKey, HostA, itemVersion: 1).ShouldBeNull();
    }

    [Fact]
    public void OneItemsEnvelope_CannotBePairedWithAnothersKeyWrap()
    {
        // What content_key_id is in the AAD for. Without it the two halves of a payload would be
        // interchangeable and a server could mix them.
        var first = HostCipher.Seal(Host(label: "one"), vaultKey, HostA, 1, 1);
        var second = HostCipher.Seal(Host(label: "two"), vaultKey, HostA, 1, 1);

        var mixed = first with { WrappedDataKey = second.WrappedDataKey };

        HostCipher.TryOpen(mixed, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
    }

    [Fact]
    public void ATamperedEnvelope_DoesNotOpen()
    {
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);

        var tampered = payload.Envelope.ToArray();
        tampered[^1] ^= 0xFF;

        HostCipher.TryOpen(payload with { Envelope = tampered }, vaultKey, HostA, 1).ShouldBeNull();
    }

    [Fact]
    public void ASubstitutedDataKeyId_DoesNotOpen()
    {
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);

        HostCipher.TryOpen(payload with { DataKeyId = Guid.CreateVersion7() }, vaultKey, HostA, 1)
            .ShouldBeNull();
    }

    [Fact]
    public void AMissingDataKey_IsRefusedRatherThanThrowing()
    {
        // What a row written before the data key existed in the contract would look like. It must degrade
        // to one unreadable item, not to an exception inside a sync pass.
        var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);

        HostCipher.TryOpen(payload with { WrappedDataKey = [] }, vaultKey, HostA, 1).ShouldBeNull();
        HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 0).ShouldBeNull();
    }

    [Fact]
    public void Seal_RefusesAVersionBelowOne()
    {
        // Versions start at 1, and a zero would silently produce a payload no push could ever match.
        Should.Throw<ArgumentOutOfRangeException>(
            () => HostCipher.Seal(Host(), vaultKey, HostA, 1, itemVersion: 0));
    }

    [Fact]
    public void Seal_RefusesAHostThatCannotBeStored()
    {
        Should.Throw<ArgumentException>(
            () => HostCipher.Seal(Host(label: "  "), vaultKey, HostA, 1, 1));
    }

    [Fact]
    public void TheNextVersion_IsOneMoreThanTheVersionBeingReplaced()
    {
        // The prediction both the sealing and the opening side depend on. If these two ever disagreed the
        // result would be an item that encrypts and never decrypts, so they share one definition.
        SyncVersions.NextVersion(null).ShouldBe(1);
        SyncVersions.NextVersion(1).ShouldBe(2);
        SyncVersions.NextVersion(41).ShouldBe(42);
    }

    [Fact]
    public void ARelayEnabledHost_ExposesItsAddressAndNothingElseDoes()
    {
        // The single point at which a hostname can leave the payload. With relay off the server learns
        // only that an item exists; see ADR 0004.
        var off = HostFields.From(Host(relayEnabled: false));
        off.RelayEnabled.ShouldBeFalse();
        off.Hostname.ShouldBeNull();
        off.Port.ShouldBeNull();

        var on = HostFields.From(Host(hostname: "bastion.internal", port: 2222, relayEnabled: true));
        on.RelayEnabled.ShouldBeTrue();
        on.Hostname.ShouldBe("bastion.internal");
        on.Port.ShouldBe(2222);
    }

    [Fact]
    public void TheRelayFlagIsInsideThePayload_SoItSurvivesARoundTrip()
    {
        // It has to be, or two clients could silently disagree about it and one would re-expose an
        // address the other had just withdrawn.
        var host = Host(relayEnabled: true);
        var payload = HostCipher.Seal(host, vaultKey, HostA, 1, 1);

        HostCipher.TryOpen(payload, vaultKey, HostA, 1)!.Host.RelayEnabled.ShouldBeTrue();
    }

    private static HostSecret Host(
        string label = "prod-db",
        string hostname = "db.internal",
        int port = 22,
        bool relayEnabled = false) =>
        new()
        {
            Label = label,
            Hostname = hostname,
            Port = port,
            Username = "deploy",
            Options = HostOptions.Create([new HostOption("Compression", "yes")]),
            RelayEnabled = relayEnabled,
        };
}
