using System.Buffers.Binary;
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Sync;

/// <summary>
/// Derives the id a resurrected item takes.
/// </summary>
/// <remarks>
/// Deterministic, from the original id and the version of the tombstone that displaced it. That matters
/// because applying a pulled change is at-least-once: the cursor is saved after the changes are applied,
/// so a process that dies in between re-applies them on the next start. A random id would resurrect the
/// same item twice and leave the user with duplicates to sort out; this way the second attempt produces
/// the same id and coalesces into the same outbox row.
/// <para>
/// Not a UUIDv7, and that is fine — the server treats item ids as opaque, and the time ordering a v7 id
/// carries is meaningless for a copy created to rescue content from a deletion.
/// </para>
/// </remarks>
internal static class ResurrectionId
{
    internal static Guid For(Guid entityId, int tombstoneVersion)
    {
        Span<byte> input = stackalloc byte[19 + 16 + sizeof(int)];

        "dsh1/resurrect/v1"u8.CopyTo(input);
        var offset = 17;

        input[offset++] = 0;
        input[offset++] = 0;

        if (!entityId.TryWriteBytes(input[offset..], bigEndian: true, out _))
        {
            throw new InvalidOperationException("Failed to write the entity id.");
        }

        offset += 16;
        BinaryPrimitives.WriteInt32BigEndian(input[offset..], tombstoneVersion);

        Span<byte> digest = stackalloc byte[32];
        SHA256.HashData(input, digest);

        return new Guid(digest[..16], bigEndian: true);
    }
}

/// <summary>
/// Reconciles one item type, with the secret type erased so the engine can hold a table of them.
/// </summary>
/// <remarks>
/// The engine never needs the concrete type — it dispatches on the entity type a change carries and lets
/// the reconciler do the rest — so this interface is what it stores. The two members are the two places
/// the push and pull paths need type-specific crypto.
/// </remarks>
internal interface IItemReconciler
{
    /// <summary>Reconciles a remote change against the operation pending for the same item.</summary>
    Task ReconcileAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken);

    /// <summary>Re-seals a queued change as a create, for a server that says it has no such row.</summary>
    /// <returns>Null on success, or the reason the change could not be re-offered.</returns>
    Task<string?> ReofferAsCreateAsync(
        Guid vaultId,
        PendingOperation pending,
        CancellationToken cancellationToken);
}

/// <summary>
/// Decides what happens when a remote change collides with an unpushed local one.
/// </summary>
/// <remarks>
/// <para>
/// Shared by the pull and the push paths, because both meet the same six situations and must answer them
/// identically — a pull that merged one way and a push that merged the other would make the outcome
/// depend on which side happened to notice first. Shared across item types for the same reason: a host
/// and an SSH key meet those six situations in exactly the same way, and the only differences —
/// encoding, merge, plaintext columns, what to call the thing — arrive through
/// <see cref="IItemKind{TSecret}"/>.
/// </para>
/// <para>
/// <b>The governing rule is that nothing is discarded silently.</b> Where the two sides can be
/// reconciled field by field, they are. Where they cannot, one value survives, the other is written to
/// the conflict log verbatim, and the user is told. Where a deletion meets an edit, the edit survives:
/// re-deleting costs a click, while a discarded edit may be the only copy of something the user cannot
/// reconstruct.
/// </para>
/// </remarks>
/// <remarks>
/// Takes no <see cref="ItemStore"/>, which is worth noticing rather than reading as an omission: nothing
/// here writes the mirror. Reconciling only ever revises the outbox and records conflicts, and the
/// server's own version of an item is written by <see cref="ItemMirror"/> before this is called.
/// </remarks>
internal sealed class ItemReconciler<TSecret>(
    IItemKind<TSecret> kind,
    OutboxStore outbox,
    ConflictStore conflicts,
    VaultKeyring keyring) : IItemReconciler
    where TSecret : class, IVaultSecret
{
    /// <summary>Reconciles a remote change against the operation pending for the same item.</summary>
    public Task ReconcileAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(remote);
        ArgumentNullException.ThrowIfNull(pending);

        if (remote.Operation == SyncOperation.Delete)
        {
            return pending.Operation == SyncOperation.Delete
                // Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
                ? outbox.CompleteAsync(pending.Sequence, cancellationToken)
                : ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
        }

        return pending.Operation == SyncOperation.Delete
            ? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken)
            : MergeAsync(vaultId, remote, pending, report, cancellationToken);
    }

    /// <summary>
    /// Re-seals a queued change as a create, for a server that says it has no such row.
    /// </summary>
    /// <remarks>
    /// The payload has to be re-sealed rather than re-sent: it was sealed at the version this client
    /// predicted, and a create produces version 1, which the AAD binds.
    /// </remarks>
    /// <returns>Null on success, or the reason the change could not be re-offered.</returns>
    public async Task<string?> ReofferAsCreateAsync(
        Guid vaultId,
        PendingOperation pending,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(pending);

        if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
            || pending.Payload is null
            || !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey))
        {
            return "This item has no usable vault key.";
        }

        // Opened under the generation it was queued at and re-sealed under the current one. Those
        // differ whenever a rotation lands between an offline edit and its push, and re-sealing is
        // the point: what goes back to the server has to be readable by everybody holding the new key.
        var local = kind.TryOpen(
            pending.Payload,
            queuedKey.Span,
            pending.EntityId,
            SyncVersions.NextVersion(pending.ExpectedVersion));

        if (local is null)
        {
            return "The queued change could not be decrypted, so it could not be re-offered.";
        }

        await outbox.ReviseAsync(
            pending.Sequence,
            SyncOperation.Upsert,
            expectedVersion: null,
            kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
            kind.Fields(local.Secret),
            ancestor: null,
            cancellationToken).ConfigureAwait(false);

        return null;
    }

    /// <summary>
    /// Reconciles a pending create that the server says already exists.
    /// </summary>
    /// <remarks>
    /// In practice this means an earlier push of the same create did land and its acknowledgement was
    /// lost — a timeout, a dropped connection — after which the local row may also have been edited. The
    /// resolution adopts the server's row as the base and re-offers the local content as an update, so
    /// the newer local state wins and no duplicate item appears. A genuine id collision between two
    /// clients is the other reading, and is not achievable with UUIDv7; if it happened, the server's
    /// values would be in the conflict log rather than gone.
    /// </remarks>
    internal async Task AdoptRemoteAsBaseAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
            .ConfigureAwait(false);

        if (opened is null)
        {
            return;
        }

        var (local, remoteSecret, vaultKey, generation) = opened.Value;

        // Through the comparer, not ==. Both secrets are records with value equality, but TSecret is a
        // type parameter, so == would bind to reference equality at compile time and never be true —
        // turning "our own create coming back" into a conflict record on every single pass.
        if (EqualityComparer<TSecret>.Default.Equals(local, remoteSecret))
        {
            // Field for field the same item: this is our own create coming back. Nothing to do but stop
            // trying to send it again.
            await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
            return;
        }

        await ReviseAsUpdateAsync(
            vaultId, remote, pending, local, vaultKey, generation, cancellationToken)
            .ConfigureAwait(false);

        await conflicts.RecordAsync(
            vaultId,
            kind.EntityType,
            remote.EntityId,
            ConflictKind.FieldOverridden,
            ConflictDetails.Encode(
                $"An item with this id already existed on the server at version {remote.Version}. "
                + "The version from this machine was kept; the server's values are recorded here."),
            cancellationToken).ConfigureAwait(false);

        report.Merged++;
    }

    /// <summary>Merges two divergent edits of the same item.</summary>
    private async Task MergeAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        if (pending.Ancestor is null)
        {
            await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return;
        }

        var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
            .ConfigureAwait(false);

        if (opened is null)
        {
            return;
        }

        var (local, remoteSecret, vaultKey, generation) = opened.Value;

        // The ancestor is the version the server last confirmed, so it carries its own generation —
        // typically the oldest of the three when a rotation has happened since.
        var ancestor =
            keyring.TryGetAt(vaultId, pending.Ancestor.Payload.KeyGeneration, out var ancestorKey)
                ? kind.TryOpen(
                    pending.Ancestor.Payload,
                    ancestorKey.Span,
                    remote.EntityId,
                    pending.Ancestor.Version)
                : null;

        if (ancestor is null)
        {
            // The base is unreadable, so a three-way merge is not possible. Falling back to a two-way
            // one would have to guess which side changed what, so the honest move is to keep the local
            // state as an update over the server's and record what was overridden.
            await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return;
        }

        var merged = kind.Merge(ancestor.Secret, local, remoteSecret);

        await ReviseAsUpdateAsync(
            vaultId, remote, pending, merged.Merged, vaultKey, generation, cancellationToken)
            .ConfigureAwait(false);

        if (merged.Conflicts.Count > 0)
        {
            await conflicts.RecordAsync(
                vaultId,
                kind.EntityType,
                remote.EntityId,
                ConflictKind.FieldOverridden,
                ConflictDetails.Encode(
                    $"'{merged.Merged.Label}' was edited in two places at once. "
                    + $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.",
                    merged.Conflicts),
                cancellationToken).ConfigureAwait(false);
        }

        report.Merged++;
    }

    /// <summary>
    /// Keeps local content that a remote deletion would otherwise take with it.
    /// </summary>
    /// <remarks>
    /// The tombstone is accepted — arguing with it would conflict for ever, since a delete beats a late
    /// upsert on the server — and the local content is re-offered under a fresh id, labelled so the user
    /// can see what happened. That is the whole of "never silently drop an item": the original goes, the
    /// work does not.
    /// </remarks>
    private async Task ResurrectAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        if (!keyring.TryGet(vaultId, out var vaultKey, out var generation))
        {
            await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return;
        }

        var local = pending.Payload is null
            || !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
            ? null
            : kind.TryOpen(
                pending.Payload,
                queuedKey.Span,
                remote.EntityId,
                SyncVersions.NextVersion(pending.ExpectedVersion));

        if (local is null || local.IsReadOnly)
        {
            await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return;
        }

        var restoredId = ResurrectionId.For(remote.EntityId, remote.Version);
        var restored = kind.Relabel(local.Secret, $"{local.Secret.Label} (restored)");

        // Queued before the original is cleared, and that order matters. These are two separate
        // transactions, so a process that dies between them has to fail in the direction that keeps the
        // work: this way the original stays pending and the next pass resurrects again — landing on the
        // same deterministic id, which coalesces into the row already queued. The other order would
        // leave the tombstone accepted and the local content gone.
        await outbox.QueueAsync(
            new QueuedChange(
                vaultId,
                kind.EntityType,
                restoredId,
                SyncOperation.Upsert,
                ExpectedVersion: null,
                kind.Seal(restored, vaultKey.Span, restoredId, generation, itemVersion: 1),
                kind.Fields(restored),
                Ancestor: null),
            cancellationToken).ConfigureAwait(false);

        // Now the tombstone can stand.
        await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);

        await conflicts.RecordAsync(
            vaultId,
            kind.EntityType,
            remote.EntityId,
            ConflictKind.RemoteDeleteResurrected,
            ConflictDetails.Encode(
                $"'{local.Secret.Label}' was deleted elsewhere while this machine had unsaved changes. "
                + $"The deletion stands and the local version was kept as '{restored.Label}'."),
            cancellationToken).ConfigureAwait(false);

        report.Resurrected++;
    }

    /// <summary>Drops a local deletion because the other side edited the item instead.</summary>
    private async Task AbandonLocalDeleteAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);

        await conflicts.RecordAsync(
            vaultId,
            kind.EntityType,
            remote.EntityId,
            ConflictKind.LocalDeleteOverridden,
            ConflictDetails.Encode(
                $"This {kind.Noun} was edited elsewhere after it was deleted here, so the deletion was "
                + "not applied. Delete it again if that is still what you want."),
            cancellationToken).ConfigureAwait(false);

        report.DeletesAbandoned++;
    }

    /// <summary>Re-offers an item as an update against the server's current version.</summary>
    private async Task ReviseAsUpdateAsync(
        Guid vaultId,
        SyncChange remote,
        PendingOperation pending,
        TSecret secret,
        ReadOnlyMemory<byte> vaultKey,
        uint generation,
        CancellationToken cancellationToken)
    {
        var nextVersion = SyncVersions.NextVersion(remote.Version);

        await outbox.ReviseAsync(
            pending.Sequence,
            SyncOperation.Upsert,
            expectedVersion: remote.Version,
            kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
            kind.Fields(secret),
            new StoredAncestor(remote.Version, remote.Payload!, remote.PlaintextFields),
            cancellationToken).ConfigureAwait(false);
    }

    /// <summary>Opens both sides of a collision, parking the operation if either will not open.</summary>
    private async Task<(TSecret Local, TSecret Remote, ReadOnlyMemory<byte> VaultKey, uint Generation)?>
        OpenPairAsync(
            Guid vaultId,
            SyncChange remote,
            PendingOperation pending,
            SyncReportBuilder report,
            CancellationToken cancellationToken)
    {
        if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
            || pending.Payload is null
            || remote.Payload is null
            || !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
            || !keyring.TryGetAt(vaultId, remote.Payload.KeyGeneration, out var remoteKey))
        {
            await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return null;
        }

        // Each side under its own generation. The two genuinely differ after a rotation: what the
        // server holds was sealed before it, and the queued edit after — or the other way round, for a
        // client that rotated while this one was offline.
        var local = kind.TryOpen(
            pending.Payload,
            queuedKey.Span,
            remote.EntityId,
            SyncVersions.NextVersion(pending.ExpectedVersion));

        var remoteSecret = kind.TryOpen(
            remote.Payload, remoteKey.Span, remote.EntityId, remote.Version);

        if (local is null || remoteSecret is null)
        {
            await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return null;
        }

        if (local.IsReadOnly || remoteSecret.IsReadOnly)
        {
            await ParkAsTooNewAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
                .ConfigureAwait(false);
            return null;
        }

        return (local.Secret, remoteSecret.Secret, vaultKey, generation);
    }

    /// <summary>
    /// Leaves an item alone because a newer client wrote it.
    /// </summary>
    /// <remarks>
    /// Re-encoding would drop fields this build cannot represent, so the item waits until this client is
    /// updated. Parked rather than merged-and-hoped: the dropped field could be the one that matters.
    /// </remarks>
    private async Task ParkAsTooNewAsync(
        Guid vaultId,
        Guid entityId,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        await outbox.ParkAsync(
            pending.Sequence,
            $"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
            cancellationToken).ConfigureAwait(false);

        await conflicts.RecordAsync(
            vaultId,
            kind.EntityType,
            entityId,
            ConflictKind.TooNewToEdit,
            ConflictDetails.Encode(
                $"This {kind.Noun} was written by a newer version of DodoSSH. It can be read but not "
                + "merged here, because saving it would discard fields this version does not know "
                + "about."),
            cancellationToken).ConfigureAwait(false);

        report.Parked++;
    }

    private async Task ParkAsync(
        Guid vaultId,
        Guid entityId,
        PendingOperation pending,
        SyncReportBuilder report,
        CancellationToken cancellationToken)
    {
        await outbox.ParkAsync(
            pending.Sequence,
            $"The local or the server copy of this {kind.Noun} could not be decrypted.",
            cancellationToken).ConfigureAwait(false);

        await conflicts.RecordAsync(
            vaultId,
            kind.EntityType,
            entityId,
            ConflictKind.Undecryptable,
            ConflictDetails.Encode(
                $"This {kind.Noun} could not be decrypted, so the change made here could not be merged. "
                + "The vault key may have been rotated, or the stored payload may not belong to this "
                + "item."),
            cancellationToken).ConfigureAwait(false);

        report.Unreadable++;
        report.Parked++;
    }
}

/// <summary>Writes the server's version of an item into the local mirror.</summary>
/// <remarks>
/// Type-agnostic on purpose, and separate from the reconcilers for that reason: mirroring copies
/// ciphertext into a row and never decrypts, so there is nothing here for an item kind to decide. Making
/// it a method on a reconciler would have meant picking one arbitrarily, or having the engine look one up
/// for a change it can mirror without knowing anything about.
/// </remarks>
internal static class ItemMirror
{
    internal static Task WriteAsync(
        ItemStore items,
        Guid vaultId,
        SyncChange change,
        CancellationToken cancellationToken) =>
        items.SaveAsync(
            new StoredItem(
                vaultId,
                change.EntityType,
                change.EntityId,
                change.Version,
                change.ChangeSequence,
                change.Payload,
                change.PlaintextFields,
                change.Operation == SyncOperation.Delete,
                change.UpdatedAt),
            cancellationToken);
}
