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

namespace DodoSSH.Client.Storage;

/// <summary>
/// Seals the few things the local cache holds that are not already ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// The cache stores item payloads exactly as the server sent them, so they need no further
/// protection. Two things do: the plaintext columns the server needs — a relay-enabled host's
/// address, chiefly — and the values a merge overrode, which are decrypted vault content by
/// definition. Both go through here.
/// </para>
/// <para>
/// <b>What this is and is not worth.</b> The key derives from the identity bundle, so it exists only
/// while the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
/// a synced-to-cloud application folder yield nothing — which is the threat this addresses. It does
/// <em>not</em> defend against a process running as the same user: that process can read this
/// process's memory, and no on-disk measure changes it. docs/crypto.md §10 says the same about a
/// compromised endpoint, and this layer does not pretend otherwise.
/// </para>
/// <para>
/// Every record is bound to its own row, so a record cannot be moved to a different row of the same
/// cache. For a relay address that is not academic: two swapped rows would aim one host's connection
/// at another host's address.
/// </para>
/// </remarks>
public sealed class LocalCacheProtector : IDisposable
{
    private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
    private bool disposed;

    private LocalCacheProtector(UserSecretBundle bundle) => bundle.DeriveLocalCacheKey(key);

    /// <summary>
    /// Derives the cache key from an opened identity bundle.
    /// </summary>
    /// <remarks>
    /// From the bundle rather than the passphrase master key, so that every way of opening the vault
    /// reaches the same cache — see <see cref="UserSecretBundle.DeriveLocalCacheKey"/>. The bundle is not
    /// retained. Only the subkey is, and it is domain-separated by its HKDF label from the key that wraps
    /// the bundle: the two live in very different threat models and must not be the same bytes.
    /// </remarks>
    public static LocalCacheProtector From(UserSecretBundle bundle)
    {
        ArgumentNullException.ThrowIfNull(bundle);

        return new LocalCacheProtector(bundle);
    }

    /// <summary>Seals a cache record, binding it to the row that will hold it.</summary>
    public byte[] Protect(
        CryptoSpec.AadResourceType resourceType,
        Guid recordId,
        ReadOnlySpan<byte> plaintext)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
    }

    /// <summary>
    /// Opens a sealed cache record.
    /// </summary>
    /// <returns>
    /// The plaintext, or <see langword="null"/> if the record does not belong to this row or this
    /// user. Null rather than an exception because a stale cache file is an ordinary situation — a
    /// changed passphrase re-derives a different key — and the caller's answer is to discard the row
    /// and re-pull, not to fail.
    /// </returns>
    public byte[]? TryUnprotect(
        CryptoSpec.AadResourceType resourceType,
        Guid recordId,
        ReadOnlySpan<byte> envelope)
    {
        ObjectDisposedException.ThrowIf(disposed, this);

        return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (disposed)
        {
            return;
        }

        disposed = true;
        CryptographicOperations.ZeroMemory(key);
    }
}

/// <summary>
/// Maps a syncable entity type onto the resource type its AAD binds.
/// </summary>
/// <remarks>
/// <para>
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
/// same list: <see cref="CryptoSpec.AadResourceType"/> also covers users, devices and vaults, so the
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
/// without failing anywhere a test would notice.
/// </para>
/// <para>
/// <b>It has to name every syncable type, and the throw is not a safety net.</b>
/// <c>ConflictStore.RecordAsync</c> calls this unconditionally, so a type with no arm here is a type
/// whose <em>conflicts</em> cannot be recorded — and the conflict log is the only reason the merge is
/// allowed to pick a winner at all. Three types went two phases without one: the two logs and the
/// buckets were added to both enums and to the reconciler registry, and this switch was not touched,
/// so a merge over any of them turned a recorded conflict into an
/// <see cref="ArgumentOutOfRangeException"/>. The two other callers hid it —
/// <c>ItemStore</c> and <c>OutboxStore</c> only reach this when an item carries plaintext fields, and
/// none of those three does.
/// </para>
/// <para>
/// <c>AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping</c> is what says so now, and it
/// is a name comparison rather than a list to keep by hand.
/// </para>
/// </remarks>
internal static class AadResourceTypes
{
    internal static CryptoSpec.AadResourceType For(SyncEntityType entityType) => entityType switch
    {
        SyncEntityType.Host => CryptoSpec.AadResourceType.Host,
        SyncEntityType.Credential => CryptoSpec.AadResourceType.Credential,
        SyncEntityType.SshKey => CryptoSpec.AadResourceType.SshKey,
        SyncEntityType.HostGroup => CryptoSpec.AadResourceType.HostGroup,
        SyncEntityType.Tag => CryptoSpec.AadResourceType.Tag,
        SyncEntityType.HostTag => CryptoSpec.AadResourceType.HostTag,
        SyncEntityType.HostCredential => CryptoSpec.AadResourceType.HostCredential,
        SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
        SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
        SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,

        // Appended in the order the enums grew, which is why these three are not beside their
        // neighbours by number. See docs/crypto.md §4.3 on why 14–16 are not one-behind their wire
        // counterparts the way the arms above are.
        SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
        SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
        SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,

        _ => throw new ArgumentOutOfRangeException(
            nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
    };
}
