using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Session;

/// <summary>
/// Records keychain changes into the vault they happened in, without making the save wait.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ConnectionRecorder"/>'s shape, and the reason is the same one stated a different way: the
/// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost
/// into every edit. So <see cref="Record"/> posts to a bounded channel and returns, and one background task
/// does the work.
/// </para>
/// <para>
/// <b>Session-scoped, unlike the connection recorder.</b> This one is created with the vault and dies with
/// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it
/// is recorded. That is why it is owned by <see cref="VaultSession"/> rather than by the shell.
/// </para>
/// <para>
/// <b>Every failure is swallowed.</b> A log write that failed and surfaced would fail a save, and the whole
/// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops
/// something is one advisory line.
/// </para>
/// </remarks>
internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
{
    /// <inheritdoc cref="ConnectionRecorder" path="/remarks/para[4]" />
    private const int QueueDepth = 512;

    private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);

    private readonly Channel<ActivityLogSecret> pending = Channel.CreateBounded<ActivityLogSecret>(
        new BoundedChannelOptions(QueueDepth)
        {
            FullMode = BoundedChannelFullMode.DropOldest,
            SingleReader = true,
        });

    private readonly ActivityLogRepository log;
    private readonly Guid vaultId;
    private readonly Guid actorUserId;
    private readonly string deviceName;
    private readonly TimeProvider clock;
    private readonly CancellationTokenSource lifetime = new();
    private readonly Task drain;

    private int disposed;

    /// <param name="log">Where entries go.</param>
    /// <param name="vaultId">The vault they belong to.</param>
    /// <param name="actorUserId">Which account is making them.</param>
    /// <param name="deviceName">What this machine calls itself.</param>
    /// <param name="clock">Time source.</param>
    internal ActivityRecorder(
        ActivityLogRepository log,
        Guid vaultId,
        Guid actorUserId,
        string deviceName,
        TimeProvider clock)
    {
        this.log = log;
        this.vaultId = vaultId;
        this.actorUserId = actorUserId;
        this.deviceName = deviceName;
        this.clock = clock;

        drain = DrainAsync(lifetime.Token);
    }

    /// <inheritdoc />
    public void Record(
        Guid vaultId,
        SyncEntityType kind,
        Guid entityId,
        string label,
        ActivityOperation operation,
        IReadOnlyList<string> changedFields)
    {
        ArgumentNullException.ThrowIfNull(changedFields);

        if (vaultId != this.vaultId)
        {
            // A write to a vault this recorder is not for. Not currently reachable — one session, one active
            // vault — and refused rather than filed under the wrong one, because that is the failure that
            // would be hardest to notice once shared vaults land.
            return;
        }

        var entry = new ActivityLogSecret
        {
            // The name rather than the number, so a build that has never heard of a kind still shows
            // something a person can read. See ActivityLogSecretCodec.
            ItemKind = Enum.GetName(kind) ?? kind.ToString(),
            ItemId = entityId,
            ItemLabel = label,
            Operation = operation,
            ChangedFields = string.Join(", ", changedFields),
            At = clock.GetUtcNow(),
            DeviceName = deviceName,
            ActorUserId = actorUserId,
        };

        pending.Writer.TryWrite(entry);
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref disposed, 1) == 1)
        {
            return;
        }

        pending.Writer.TryComplete();

        try
        {
            await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
        {
            // Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes.
        }

        await lifetime.CancelAsync().ConfigureAwait(false);

        try
        {
            await drain.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            // Expected: cancelling is how the loop is asked to stop.
        }

        lifetime.Dispose();
    }

    private async Task DrainAsync(CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
            {
                try
                {
                    await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false);
                }
                catch (Exception exception) when (exception is not OperationCanceledException)
                {
                    // Swallowed. There is no caller left to tell, and the realistic failure is a cache that
                    // has gone away underneath a session being disposed.
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Shutting down.
        }
    }
}
