using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;

namespace DodoSSH.Client.Sync;

/// <summary>What one re-sealing pass did.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">The generation everything was moved to.</param>
/// <param name="Resealed">Items now sealed under the current key.</param>
/// <param name="Deferred">
/// Items left alone because a local edit is queued for them. Not a failure: a queued change is re-sealed
/// as it is pushed, so these arrive at the current generation by another route.
/// </param>
/// <param name="Unreadable">
/// Items whose own generation this session holds no key for. They stay where they are — the alternative
/// is discarding an item nobody can read yet, which is the one outcome that cannot be undone.
/// </param>
/// <param name="Contested">
/// Items somebody else wrote while this pass was running. The server refused the version, and the next
/// pass picks them up against the version it left behind.
/// </param>
public sealed record ResealReport(
    Guid VaultId,
    uint KeyGeneration,
    int Resealed,
    int Deferred,
    int Unreadable,
    int Contested)
{
    /// <summary>Whether every item in the vault is now sealed under its current key.</summary>
    /// <remarks>
    /// Deferred items count as finished. They are queued changes, and a queued change cannot reach the
    /// server under a superseded key — <see cref="SyncEngine"/> re-seals it on the way out.
    /// </remarks>
    public bool Complete => Unreadable == 0 && Contested == 0;

    /// <summary>Whether anything moved.</summary>
    public bool MovedAnything => Resealed > 0;
}

/// <summary>
/// Moves a rotated vault's stored items onto its current key.
/// </summary>
/// <remarks>
/// <para>
/// <b>What a rotation on its own does not do.</b> Advancing a vault's generation re-keys the vault and
/// not its contents: every item stays sealed under the generation it was written with, readable by
/// anybody holding that generation's grant. That is what keeps a rotation cheap and safe — see
/// ADR 0010 — and it leaves one gap, which is this pass. Somebody who left with a copy of the old key
/// could still open old ciphertext they later got hold of. Once this has run, they cannot: every item
/// is sealed under a key issued after they went.
/// </para>
/// <para>
/// <b>The plaintext is never decoded.</b> An item is opened, and the same bytes are sealed again under
/// a fresh data key — no codec, no merge, no schema version. So an item written by a newer client
/// survives this untouched, where re-encoding it through this build's codec would silently drop the
/// fields this build has no concept of. It also means nothing here needs to know what an item *is*,
/// which is why one pass covers every type including the ones added later.
/// </para>
/// <para>
/// <b>Resumable by construction, because a vault at mixed generations is readable.</b> Each item is one
/// ordinary upsert against the version the server holds, so a pass that dies half way leaves a working
/// vault, and running it again picks up what is left. Nothing here is a transaction and nothing needs
/// to be.
/// </para>
/// </remarks>
public sealed class VaultResealer
{
    private readonly ISyncApi api;
    private readonly ItemStore items;
    private readonly OutboxStore outbox;
    private readonly VaultKeyring keyring;
    private readonly TimeProvider clock;
    private readonly SyncOptions options;

    /// <summary>Creates the pass.</summary>
    public VaultResealer(
        ISyncApi api,
        ItemStore items,
        OutboxStore outbox,
        VaultKeyring keyring,
        TimeProvider clock,
        SyncOptions? options = null)
    {
        ArgumentNullException.ThrowIfNull(api);
        ArgumentNullException.ThrowIfNull(items);
        ArgumentNullException.ThrowIfNull(outbox);
        ArgumentNullException.ThrowIfNull(keyring);
        ArgumentNullException.ThrowIfNull(clock);

        this.api = api;
        this.items = items;
        this.outbox = outbox;
        this.keyring = keyring;
        this.clock = clock;
        this.options = options ?? SyncOptions.Default;
    }

    /// <summary>Re-seals everything in one vault that is not already on its current key.</summary>
    /// <param name="vaultId">The vault.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <remarks>
    /// Answers with a report of zero for a vault this session cannot write to, rather than throwing.
    /// Being rotated past and not yet re-wrapped is the ordinary state for a member between somebody
    /// else's rotation and their own re-grant, and it is not this pass's business to complain about it.
    /// </remarks>
    public async Task<ResealReport> ResealAsync(Guid vaultId, CancellationToken cancellationToken)
    {
        if (!keyring.TryGet(vaultId, out _, out var generation))
        {
            return new ResealReport(vaultId, KeyGeneration: 0, 0, 0, 0, 0);
        }

        var queued = await QueuedAsync(vaultId, cancellationToken).ConfigureAwait(false);

        var tally = new Tally();
        var batch = new List<Pending>(options.MaxOperationsPerPush);

        foreach (var entityType in ItemKinds.SyncedTypes)
        {
            var stored = await items
                .ListAsync(vaultId, entityType, includeDeleted: false, cancellationToken)
                .ConfigureAwait(false);

            foreach (var item in stored.Where(item => Behind(item, generation)))
            {
                if (queued.Contains((entityType, item.EntityId)))
                {
                    tally.Deferred++;
                    continue;
                }

                if (Move(vaultId, item) is not { } moved)
                {
                    tally.Unreadable++;
                    continue;
                }

                batch.Add(moved);

                if (batch.Count == options.MaxOperationsPerPush)
                {
                    await SendAsync(vaultId, batch, tally, cancellationToken).ConfigureAwait(false);
                }
            }
        }

        await SendAsync(vaultId, batch, tally, cancellationToken).ConfigureAwait(false);

        return new ResealReport(
            vaultId, generation, tally.Resealed, tally.Deferred, tally.Unreadable, tally.Contested);
    }

    /// <summary>Whether an item is still sealed under a key the vault has moved past.</summary>
    private static bool Behind(StoredItem item, uint generation) =>
        item.Payload is { } payload && payload.KeyGeneration < generation;

    /// <summary>Re-seals one item, or answers null when this session cannot open it.</summary>
    private Pending? Move(Guid vaultId, StoredItem item)
    {
        var version = SyncVersions.NextVersion(item.Version);

        var payload = PayloadReseal.TryReseal(
            keyring, vaultId, item.EntityType, item.EntityId, item.Payload!, item.Version, version);

        return payload is null ? null : new Pending(item, payload, version);
    }

    /// <summary>Sends a batch if there is one, and empties it.</summary>
    private async Task SendAsync(
        Guid vaultId,
        List<Pending> batch,
        Tally tally,
        CancellationToken cancellationToken)
    {
        if (batch.Count == 0)
        {
            return;
        }

        var outcome = await PushAsync(vaultId, batch, cancellationToken).ConfigureAwait(false);

        tally.Resealed += outcome.Applied;
        tally.Contested += outcome.Contested;

        batch.Clear();
    }

    /// <summary>The running counts, so the loop above stays one screen long.</summary>
    private sealed class Tally
    {
        internal int Resealed { get; set; }

        internal int Deferred { get; set; }

        internal int Unreadable { get; set; }

        internal int Contested { get; set; }
    }

    /// <summary>One item, re-sealed and waiting to be sent.</summary>
    private sealed record Pending(StoredItem Item, EncryptedPayload Payload, int Version)
    {
        internal Guid OperationId { get; } = Guid.CreateVersion7();
    }

    /// <summary>What one batch achieved.</summary>
    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
    private readonly record struct PushOutcome(int Applied, int Contested);

    /// <summary>
    /// Sends one batch and mirrors what the server accepted.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The mirror is written here rather than left to the next pull, so that a pass followed immediately
    /// by another does not re-seal everything a second time. It is the same row with the same plaintext
    /// under a new key, so there is nothing for a reader to notice.
    /// </para>
    /// <para>
    /// A conflict is counted and skipped. There is nothing to merge — this pass changes no content — and
    /// re-reading the item at the version the other client left is exactly what the next pass does.
    /// </para>
    /// </remarks>
    private async Task<PushOutcome> PushAsync(
        Guid vaultId,
        List<Pending> batch,
        CancellationToken cancellationToken)
    {
        var operations = batch
            .Select(pending => new SyncPushOperation(
                pending.OperationId,
                pending.Item.EntityType,
                pending.Item.EntityId,
                SyncOperation.Upsert,
                pending.Item.Version,
                pending.Payload,
                pending.Item.Fields))
            .ToList();

        var response = await api
            .SyncPushAsync(vaultId, new SyncPushRequest(operations), cancellationToken)
            .ConfigureAwait(false);

        var applied = 0;
        var contested = 0;

        foreach (var result in response.Results)
        {
            if (batch.Find(pending => pending.OperationId == result.OperationId) is not { } sent)
            {
                continue;
            }

            if (result.Status is not (SyncOperationStatus.Applied or SyncOperationStatus.Duplicate))
            {
                contested++;
                continue;
            }

            applied++;

            await items.SaveAsync(
                    sent.Item with
                    {
                        Version = result.Version ?? sent.Version,
                        ChangeSequence = result.ChangeSequence ?? sent.Item.ChangeSequence,
                        Payload = sent.Payload,
                        UpdatedAt = clock.GetUtcNow(),
                    },
                    cancellationToken)
                .ConfigureAwait(false);
        }

        return new PushOutcome(applied, contested);
    }

    /// <summary>The items a local edit is already queued for.</summary>
    private async Task<HashSet<(SyncEntityType Type, Guid EntityId)>> QueuedAsync(
        Guid vaultId,
        CancellationToken cancellationToken)
    {
        var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false);

        return [.. pending.Select(operation => (operation.EntityType, operation.EntityId))];
    }
}

/// <summary>
/// Moving one payload from the key it was sealed under to the one in force now.
/// </summary>
/// <remarks>
/// Used from two places, and both of them matter: the pass above, which walks a rotated vault, and the
/// push path, which cannot be allowed to send a change queued before a rotation under the key it was
/// queued with. Between them they are the guarantee that nothing reaches the server under a superseded
/// generation.
/// </remarks>
internal static class PayloadReseal
{
    /// <summary>
    /// Re-seals a payload under the vault's current key.
    /// </summary>
    /// <param name="keyring">The open keyring.</param>
    /// <param name="vaultId">The vault.</param>
    /// <param name="entityType">What kind of item this is; the AAD binds its resource type.</param>
    /// <param name="entityId">The item.</param>
    /// <param name="payload">The payload as it stands, sealed under an earlier generation.</param>
    /// <param name="openAtVersion">The item version <paramref name="payload"/> is bound to.</param>
    /// <param name="sealAtVersion">The version the result will be bound to.</param>
    /// <returns>
    /// The re-sealed payload, or <see langword="null"/> when this session cannot open the original —
    /// which means one item stays where it is, and says nothing about the rest of the vault.
    /// </returns>
    /// <remarks>
    /// A fresh data key, not the original one re-wrapped. The two cost the same here, because the AAD
    /// binds the generation into the payload as well as into the key wrap and the envelope has to be
    /// re-made either way — and a new key per version is the rule the whole item format is built on.
    /// </remarks>
    internal static EncryptedPayload? TryReseal(
        VaultKeyring keyring,
        Guid vaultId,
        SyncEntityType entityType,
        Guid entityId,
        EncryptedPayload payload,
        int openAtVersion,
        int sealAtVersion)
    {
        if (!keyring.TryGetAt(vaultId, payload.KeyGeneration, out var previous)
            || !keyring.TryGet(vaultId, out var current, out var generation))
        {
            return null;
        }

        var resource = AadResourceTypes.For(entityType);

        var dataKey = ItemKeys.TryUnwrapDataKey(
            previous.Span,
            payload.WrappedDataKey,
            resource,
            entityId,
            payload.KeyGeneration,
            (uint)openAtVersion);

        if (dataKey is null)
        {
            return null;
        }

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

            if (plaintext is null)
            {
                return null;
            }

            try
            {
                return Seal(
                    plaintext, current.Span, resource, entityId, generation, (uint)sealAtVersion);
            }
            finally
            {
                // The one place in this file that holds an item's plaintext, and it holds every kind of
                // item there is — a private key, a password, a snippet with a token pasted into it.
                CryptographicOperations.ZeroMemory(plaintext);
            }
        }
        finally
        {
            CryptographicOperations.ZeroMemory(dataKey);
        }
    }

    private static EncryptedPayload Seal(
        ReadOnlySpan<byte> plaintext,
        ReadOnlySpan<byte> vaultKey,
        CryptoSpec.AadResourceType resource,
        Guid entityId,
        uint keyGeneration,
        uint itemVersion)
    {
        var dataKey = ItemKeys.CreateDataKey();

        try
        {
            var dataKeyId = Guid.CreateVersion7();

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

            var wrapped = ItemKeys.WrapDataKey(
                dataKey, vaultKey, resource, entityId, keyGeneration, itemVersion);

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