using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Client.Storage;

/// <summary>
/// The mirror of what the server holds.
/// </summary>
/// <remarks>
/// <para>
/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
/// this table — that lives in <see cref="OutboxStore"/>, together with the ancestor it branched from.
/// Keeping the two apart is what makes a three-way merge possible: a single table holding "the current
/// local view" would have overwritten the ancestor and left only a two-way diff, which cannot tell an
/// edit from a revert.
/// </para>
/// <para>
/// Requires an unlocked <see cref="LocalCacheProtector"/>, which is deliberate. The protected columns
/// have to be sealed on every write and opened on every read, and a store that could be constructed
/// without a key would be a store that could write one of them in the clear.
/// </para>
/// </remarks>
public sealed class ItemStore(
    IDbContextFactory<ClientCacheContext> contexts,
    LocalCacheProtector protector)
{
    /// <summary>Reads one item, tombstones included.</summary>
    public async Task<StoredItem?> FindAsync(
        Guid vaultId,
        SyncEntityType entityType,
        Guid entityId,
        CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var row = await context.Set<CachedItemRow>()
            .AsNoTracking()
            .SingleOrDefaultAsync(
                r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
                cancellationToken)
            .ConfigureAwait(false);

        return row is null ? null : ToStored(row);
    }

    /// <summary>Reads every item of one kind in a vault.</summary>
    /// <param name="vaultId">The vault.</param>
    /// <param name="entityType">Kind of item.</param>
    /// <param name="includeDeleted">
    /// Whether to return tombstones. The interface wants them excluded; the sync engine wants them,
    /// because a tombstone is the only record that an item it once knew about has gone.
    /// </param>
    /// <param name="cancellationToken">Cancellation token.</param>
    public async Task<IReadOnlyList<StoredItem>> ListAsync(
        Guid vaultId,
        SyncEntityType entityType,
        bool includeDeleted,
        CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var query = context.Set<CachedItemRow>()
            .AsNoTracking()
            .Where(r => r.VaultId == vaultId && r.EntityType == entityType);

        if (!includeDeleted)
        {
            query = query.Where(r => !r.IsDeleted);
        }

        var rows = await query
            .OrderBy(r => r.ChangeSequence)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

        return [.. rows.Select(ToStored)];
    }

    /// <summary>
    /// Writes the server's version of an item, creating or replacing the row.
    /// </summary>
    /// <remarks>
    /// Deliberately a blind overwrite. This is a mirror, and the server's answer is the truth about
    /// what the server holds; a local edit that must survive is in the outbox, and it is the sync
    /// engine's job to have merged it before calling this.
    /// </remarks>
    public async Task SaveAsync(StoredItem item, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(item);

        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var row = await context.Set<CachedItemRow>()
            .SingleOrDefaultAsync(
                r => r.VaultId == item.VaultId
                    && r.EntityType == item.EntityType
                    && r.EntityId == item.EntityId,
                cancellationToken)
            .ConfigureAwait(false);

        if (row is null)
        {
            row = new CachedItemRow
            {
                VaultId = item.VaultId,
                EntityType = item.EntityType,
                EntityId = item.EntityId,
            };

            context.Add(row);
        }

        Apply(row, item);

        await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
    }

    /// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
    /// <remarks>
    /// Only ever called for a row that is already a tombstone. Collecting a live item here would make
    /// it indistinguishable from one this client has never seen, and it would silently reappear on the
    /// next full pull.
    /// </remarks>
    public async Task<int> CollectTombstonesAsync(
        Guid vaultId,
        DateTimeOffset olderThan,
        CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        return await context.Set<CachedItemRow>()
            .Where(r => r.VaultId == vaultId && r.IsDeleted && r.UpdatedAtUtc < olderThan)
            .ExecuteDeleteAsync(cancellationToken)
            .ConfigureAwait(false);
    }

    private void Apply(CachedItemRow row, StoredItem item)
    {
        row.Version = item.Version;
        row.ChangeSequence = item.ChangeSequence;
        row.IsDeleted = item.IsDeleted;
        row.UpdatedAtUtc = item.UpdatedAt;

        row.Payload = item.Payload?.Envelope;
        row.WrappedDataKey = item.Payload?.WrappedDataKey;
        row.DataKeyId = item.Payload?.DataKeyId;
        row.KeyGeneration = item.Payload?.KeyGeneration ?? 0;
        row.AadVersion = item.Payload?.AadVersion ?? 0;

        row.ProtectedFields = item.Fields is null
            ? null
            : protector.Protect(
                AadResourceTypes.For(item.EntityType),
                item.EntityId,
                PlaintextFieldsCodec.Encode(item.Fields));
    }

    private StoredItem ToStored(CachedItemRow row) =>
        new(
            row.VaultId,
            row.EntityType,
            row.EntityId,
            row.Version,
            row.ChangeSequence,
            CacheMapping.ToPayload(
                row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
            OpenFields(row.EntityType, row.EntityId, row.ProtectedFields),
            row.IsDeleted,
            row.UpdatedAtUtc);

    /// <remarks>
    /// A record that will not open is treated as absent rather than fatal. The cache is not the
    /// authority — a re-pull restores it — and the alternative is one stale row aborting a sync pass
    /// and stranding every change behind it.
    /// </remarks>
    private SyncPlaintextFields? OpenFields(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
    {
        if (sealedFields is null)
        {
            return null;
        }

        var plaintext = protector.TryUnprotect(
            AadResourceTypes.For(entityType), entityId, sealedFields);

        return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
    }
}
