using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Sync;

/// <summary>
/// Turns a host into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Every operation binds to the item's identity, its key generation <em>and</em> its version, because
/// that is what <c>DshAad.ItemPayload</c> requires. The version part has a consequence worth stating
/// plainly: a payload must be sealed at the version the server will assign, not the version it is
/// replacing. See <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// The data key is fresh per call and is zeroed before returning, as is the encoded plaintext. Neither
/// is ever handed to a caller: a data key that escaped this class would be a data key some other layer
/// could forget to clear.
/// </para>
/// </remarks>
public static class HostCipher
{
    private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Host;

    /// <summary>
    /// Encrypts a host.
    /// </summary>
    /// <param name="host">The host. Must be valid for storage.</param>
    /// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
    /// <param name="entityId">The item id, which the AAD binds.</param>
    /// <param name="keyGeneration">The vault's current key generation.</param>
    /// <param name="itemVersion">
    /// The version this payload will hold once the server accepts it — one more than the version being
    /// replaced. Sealing at the version being replaced would produce a payload that authenticates
    /// against a row that no longer exists, and the item would read as corrupt from then on.
    /// </param>
    public static EncryptedPayload Seal(
        HostSecret host,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        uint keyGeneration,
        int itemVersion)
    {
        ArgumentNullException.ThrowIfNull(host);
        ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);

        var plaintext = HostSecretCodec.Encode(host);
        var dataKey = ItemKeys.CreateDataKey();

        try
        {
            var dataKeyId = Guid.CreateVersion7();

            var wrappedDataKey = ItemKeys.WrapDataKey(
                dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);

            var envelope = ItemKeys.SealPayload(
                dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);

            return new EncryptedPayload(
                envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(dataKey);
            CryptographicOperations.ZeroMemory(plaintext);
        }
    }

    /// <summary>
    /// Decrypts a host.
    /// </summary>
    /// <returns>
    /// The host and the schema version it was written at, or <see langword="null"/> if the payload does
    /// not belong to this item, version or generation, or does not parse.
    /// <para>
    /// A null is a meaningful outcome, not an error to be thrown past. It is what a server relocating
    /// ciphertext between rows looks like from here, and it is also what an ordinary rekey looks like
    /// before new grants arrive. The caller distinguishes them by comparing generations; either way one
    /// unreadable item must not abort a sync pass and strand every change behind it.
    /// </para>
    /// </returns>
    public static HostSecretDocument? TryOpen(
        EncryptedPayload payload,
        ReadOnlySpan<byte> vaultKey,
        Guid entityId,
        int itemVersion)
    {
        ArgumentNullException.ThrowIfNull(payload);

        if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
        {
            return null;
        }

        var dataKey = ItemKeys.TryUnwrapDataKey(
            vaultKey,
            payload.WrappedDataKey,
            Resource,
            entityId,
            payload.KeyGeneration,
            (uint)itemVersion);

        if (dataKey is null)
        {
            return null;
        }

        try
        {
            var plaintext = ItemKeys.TryOpenPayload(
                dataKey,
                payload.Envelope,
                Resource,
                entityId,
                payload.DataKeyId,
                payload.KeyGeneration,
                (uint)itemVersion);

            if (plaintext is null)
            {
                return null;
            }

            try
            {
                return HostSecretCodec.TryDecode(plaintext, out var document) ? document : null;
            }
            finally
            {
                CryptographicOperations.ZeroMemory(plaintext);
            }
        }
        finally
        {
            CryptographicOperations.ZeroMemory(dataKey);
        }
    }
}

/// <summary>
/// The one place that decides which item version a payload is sealed at.
/// </summary>
/// <remarks>
/// The payload's AAD binds the item version, so the sealing side has to predict what the server will
/// assign. That prediction is safe because it is checked: the server applies an upsert only when
/// <c>expectedVersion</c> matches, and then increments by exactly one. A mismatch is a conflict, not a
/// silently mis-sealed row. Both the sealing and the opening sides go through here, so they cannot
/// drift — the failure if they did would be an item that encrypts fine and never decrypts again.
/// </remarks>
internal static class SyncVersions
{
    /// <summary>The version an accepted upsert will produce.</summary>
    /// <param name="expectedVersion">The version being replaced, or null for a create.</param>
    internal static int NextVersion(int? expectedVersion) => (expectedVersion ?? 0) + 1;
}

/// <summary>
/// Derives the plaintext columns the server needs from a host.
/// </summary>
/// <remarks>
/// <para>
/// The single point at which a hostname can leave the encrypted payload, which is the whole reason it
/// is a function rather than something each call site assembles. The address is emitted only when the
/// user has turned the relay on for that host; with relay off, the server learns nothing but that an
/// item exists. See ADR 0004 for why the relay cannot work any other way.
/// </para>
/// <para>
/// <b>The port is the host's own, and it is allowed to be, because a relay host may not inherit one.</b>
/// This runs from inside the generic write path — see <c>IItemKind{TSecret}.Fields</c> — which holds one
/// secret and has no group list to walk, and threading one in would put the group chain inside the sync
/// engine for the sake of a single column. It does not have to: <see cref="HostSecret.TryValidate"/>
/// refuses a relay host with no port of its own, so the branch below cannot be reached by a host that
/// inherits. That refusal exists for a stronger reason than this convenience — a plaintext column derived
/// from another item goes stale when that item is edited, and nothing re-pushes the hosts beneath it.
/// </para>
/// </remarks>
internal static class HostFields
{
    internal static SyncPlaintextFields From(HostSecret host) =>
        host.RelayEnabled
            ? new SyncPlaintextFields(RelayEnabled: true, Hostname: host.Hostname, Port: host.Port)
            : new SyncPlaintextFields();
}
