using System.Threading.Channels;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Microsoft.EntityFrameworkCore;

namespace Ikar.Server.Infrastructure.Push;

public enum PushDispatchKind
{
    ChatMessage = 0,
    MessageReaction = 1
}

public sealed record PushDispatchWorkItem(
    Guid ChatId,
    Guid MessageId,
    PushDispatchKind Kind = PushDispatchKind.ChatMessage,
    Guid? ActorUserId = null,
    string? ReactionEmoji = null,
    long? OutboxId = null);

public sealed class PushDispatchQueue(IServiceScopeFactory? serviceScopeFactory = null)
{
    private const int BatchSize = 50;
    private readonly Channel<PushDispatchWorkItem> _fallbackChannel =
        Channel.CreateUnbounded<PushDispatchWorkItem>(new UnboundedChannelOptions
        {
            SingleReader = true,
            SingleWriter = false
        });
    private readonly Channel<bool> _wakeSignals = Channel.CreateBounded<bool>(
        new BoundedChannelOptions(1)
        {
            SingleReader = true,
            SingleWriter = false,
            FullMode = BoundedChannelFullMode.DropOldest
        });

    public bool IsDurable => serviceScopeFactory is not null;

    public void StageMessages(IkarDbContext dbContext, params Message?[] messages)
    {
        if (!IsDurable)
        {
            return;
        }

        foreach (var message in messages.Where(message => message is not null).Cast<Message>())
        {
            StageWorkItem(
                dbContext,
                new PushDispatchWorkItem(message.ChatId, message.Id),
                $"message:{message.Id:D}");
        }
    }

    public void StageReaction(
        IkarDbContext dbContext,
        Guid chatId,
        Guid messageId,
        Guid reactorUserId,
        string emoji)
    {
        if (!IsDurable)
        {
            return;
        }

        StageWorkItem(
            dbContext,
            new PushDispatchWorkItem(
                chatId,
                messageId,
                PushDispatchKind.MessageReaction,
                reactorUserId,
                emoji),
            $"reaction:{messageId:D}:{reactorUserId:D}:{emoji}");
    }

    public ValueTask EnqueueAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken = default) =>
        EnqueueWorkItemAsync(
            new PushDispatchWorkItem(chatId, messageId),
            $"message:{messageId:D}",
            cancellationToken);

    public ValueTask EnqueueReactionAsync(
        Guid chatId,
        Guid messageId,
        Guid reactorUserId,
        string emoji,
        CancellationToken cancellationToken = default) =>
        EnqueueWorkItemAsync(
            new PushDispatchWorkItem(
                chatId,
                messageId,
                PushDispatchKind.MessageReaction,
                reactorUserId,
                emoji),
            $"reaction:{messageId:D}:{reactorUserId:D}:{emoji}",
            cancellationToken);

    public IAsyncEnumerable<PushDispatchWorkItem> ReadAllAsync(CancellationToken cancellationToken) =>
        _fallbackChannel.Reader.ReadAllAsync(cancellationToken);

    public async Task<IReadOnlyList<PushDispatchWorkItem>> LoadDueBatchAsync(CancellationToken cancellationToken)
    {
        if (serviceScopeFactory is null)
        {
            return [];
        }

        await using var scope = serviceScopeFactory.CreateAsyncScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
        var now = DateTimeOffset.UtcNow;
        return await dbContext.PushDispatchOutbox
            .AsNoTracking()
            .Where(item => item.NextAttemptAt <= now)
            .OrderBy(item => item.Id)
            .Take(BatchSize)
            .Select(item => new PushDispatchWorkItem(
                item.ChatId,
                item.MessageId,
                (PushDispatchKind)item.Kind,
                item.ActorUserId,
                item.ReactionEmoji,
                item.Id))
            .ToListAsync(cancellationToken);
    }

    public async Task CompleteAsync(long outboxId, CancellationToken cancellationToken)
    {
        if (serviceScopeFactory is null)
        {
            return;
        }

        await using var scope = serviceScopeFactory.CreateAsyncScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
        await dbContext.PushDispatchOutbox
            .Where(item => item.Id == outboxId)
            .ExecuteDeleteAsync(cancellationToken);
    }

    public async Task RescheduleAsync(long outboxId, Exception error, CancellationToken cancellationToken)
    {
        if (serviceScopeFactory is null)
        {
            return;
        }

        await using var scope = serviceScopeFactory.CreateAsyncScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
        var item = await dbContext.PushDispatchOutbox.SingleOrDefaultAsync(x => x.Id == outboxId, cancellationToken);
        if (item is null)
        {
            return;
        }

        item.AttemptCount++;
        var delaySeconds = Math.Min(900, 5 * (1 << Math.Min(item.AttemptCount, 8)));
        item.NextAttemptAt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
        item.LastError = error.Message[..Math.Min(error.Message.Length, 1000)];
        await dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task<int> CountPendingAsync(CancellationToken cancellationToken = default)
    {
        if (serviceScopeFactory is null)
        {
            return 0;
        }

        await using var scope = serviceScopeFactory.CreateAsyncScope();
        return await scope.ServiceProvider.GetRequiredService<IkarDbContext>()
            .PushDispatchOutbox.CountAsync(cancellationToken);
    }

    public async Task WaitForWorkAsync(CancellationToken cancellationToken)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeout.CancelAfter(TimeSpan.FromSeconds(5));
        try
        {
            await _wakeSignals.Reader.ReadAsync(timeout.Token);
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
        }
    }

    private async ValueTask EnqueueWorkItemAsync(
        PushDispatchWorkItem workItem,
        string deduplicationKey,
        CancellationToken cancellationToken)
    {
        if (serviceScopeFactory is null)
        {
            await _fallbackChannel.Writer.WriteAsync(workItem, cancellationToken);
            return;
        }

        await using var scope = serviceScopeFactory.CreateAsyncScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
        dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem
        {
            DeduplicationKey = deduplicationKey,
            ChatId = workItem.ChatId,
            MessageId = workItem.MessageId,
            Kind = (int)workItem.Kind,
            ActorUserId = workItem.ActorUserId,
            ReactionEmoji = workItem.ReactionEmoji
        });
        try
        {
            await dbContext.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateException)
        {
            dbContext.ChangeTracker.Clear();
            if (await dbContext.PushDispatchOutbox
                .AsNoTracking()
                .AnyAsync(item => item.DeduplicationKey == deduplicationKey, cancellationToken))
            {
                _wakeSignals.Writer.TryWrite(true);
                return;
            }

            throw;
        }

        _wakeSignals.Writer.TryWrite(true);
    }

    private static void StageWorkItem(
        IkarDbContext dbContext,
        PushDispatchWorkItem workItem,
        string deduplicationKey)
    {
        if (dbContext.PushDispatchOutbox.Local.Any(item => item.DeduplicationKey == deduplicationKey))
        {
            return;
        }

        dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem
        {
            DeduplicationKey = deduplicationKey,
            ChatId = workItem.ChatId,
            MessageId = workItem.MessageId,
            Kind = (int)workItem.Kind,
            ActorUserId = workItem.ActorUserId,
            ReactionEmoji = workItem.ReactionEmoji
        });
    }
}
