using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;

namespace DodoSSH.Client.Storage;

/// <summary>
/// What the merge had to override, and what it could not process.
/// </summary>
/// <remarks>
/// <para>
/// This table is what makes automatic merging defensible. The merge picks a winner field by field,
/// which is only acceptable because the loser lands here verbatim and gets shown. Without it, a
/// field-level merge is last-writer-wins with a longer explanation.
/// </para>
/// <para>
/// The detail is sealed under the LocalCacheKey, because it is the one place the cache deliberately
/// holds decrypted vault content — a password someone typed that another edit displaced. It is exactly
/// as sensitive as the item it came from and is treated that way.
/// </para>
/// </remarks>
public sealed class ConflictStore(
    IDbContextFactory<ClientCacheContext> contexts,
    LocalCacheProtector protector,
    TimeProvider clock)
{
    /// <summary>
    /// Records a conflict.
    /// </summary>
    /// <remarks>
    /// The record's own id is generated here and the detail is bound to it, so one conflict's discarded
    /// values can never be read back against another's row.
    /// </remarks>
    public async Task<Guid> RecordAsync(
        Guid vaultId,
        SyncEntityType entityType,
        Guid entityId,
        ConflictKind kind,
        ReadOnlyMemory<byte> detail,
        CancellationToken cancellationToken)
    {
        if (kind == ConflictKind.Unspecified)
        {
            throw new ArgumentOutOfRangeException(nameof(kind), kind, "A conflict kind is required.");
        }

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

        var id = Guid.CreateVersion7();

        context.Add(new ConflictRow
        {
            Id = id,
            VaultId = vaultId,
            EntityType = entityType,
            EntityId = entityId,
            Kind = kind,
            Detail = protector.Protect(AadResourceTypes.For(entityType), id, detail.Span),
            DetectedAtUtc = clock.GetUtcNow(),
            Acknowledged = false,
        });

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

        return id;
    }

    /// <summary>Reads conflicts for a vault, newest first.</summary>
    public async Task<IReadOnlyList<StoredConflict>> ListAsync(
        Guid vaultId,
        bool includeAcknowledged,
        CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var query = context.Set<ConflictRow>()
            .AsNoTracking()
            .Where(row => row.VaultId == vaultId);

        if (!includeAcknowledged)
        {
            query = query.Where(row => !row.Acknowledged);
        }

        var rows = await query
            .OrderByDescending(row => row.DetectedAtUtc)
            .ToListAsync(cancellationToken)
            .ConfigureAwait(false);

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

    /// <summary>Marks a conflict as dealt with.</summary>
    /// <remarks>
    /// Acknowledged rather than deleted, so the discarded value stays recoverable after the user has
    /// dismissed the notification. Someone who clicks past a warning and realises a minute later that
    /// they wanted the other value should still be able to get it.
    /// </remarks>
    public async Task<bool> AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var updated = await context.Set<ConflictRow>()
            .Where(row => row.Id == conflictId)
            .ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken)
            .ConfigureAwait(false);

        return updated > 0;
    }

    /// <summary>Removes an acknowledged conflict for good.</summary>
    public async Task<bool> DiscardAsync(Guid conflictId, CancellationToken cancellationToken)
    {
        var context = contexts.CreateDbContext();
        await using var scope = context.ConfigureAwait(false);

        var removed = await context.Set<ConflictRow>()
            .Where(row => row.Id == conflictId && row.Acknowledged)
            .ExecuteDeleteAsync(cancellationToken)
            .ConfigureAwait(false);

        return removed > 0;
    }

    /// <remarks>
    /// A detail that will not open surfaces as empty rather than as a failure. The conflict itself — its
    /// kind, its item, its timestamp — is still worth showing even when the discarded value has become
    /// unreadable, for instance after a passphrase change re-derived the cache key.
    /// </remarks>
    private StoredConflict ToStored(ConflictRow row) =>
        new(
            row.Id,
            row.VaultId,
            row.EntityType,
            row.EntityId,
            row.Kind,
            protector.TryUnprotect(AadResourceTypes.For(row.EntityType), row.Id, row.Detail) ?? [],
            row.DetectedAtUtc,
            row.Acknowledged);
}
