using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;

namespace StellaOps.Scanner.Queue.Redis;

internal sealed class RedisScanQueue : IScanQueue, IAsyncDisposable
{
    private const string TransportName = "redis";

    private readonly ScannerQueueOptions _queueOptions;
    private readonly RedisQueueOptions _options;
    private readonly ILogger<RedisScanQueue> _logger;
    private readonly TimeProvider _timeProvider;
    private readonly SemaphoreSlim _connectionLock = new(1, 1);
    private readonly SemaphoreSlim _groupInitLock = new(1, 1);
    private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
    private IConnectionMultiplexer? _connection;
    private volatile bool _groupInitialized;
    private bool _disposed;

    private string BuildIdempotencyKey(string key)
        => string.Concat(_options.IdempotencyKeyPrefix, key);

    public RedisScanQueue(
        ScannerQueueOptions queueOptions,
        RedisQueueOptions options,
        ILogger<RedisScanQueue> logger,
        TimeProvider timeProvider,
        Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
    {
        _queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _timeProvider = timeProvider ?? TimeProvider.System;
        _connectionFactory = connectionFactory ?? (config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));

        if (string.IsNullOrWhiteSpace(_options.ConnectionString))
        {
            throw new InvalidOperationException("Redis connection string must be configured for the scanner queue.");
        }
    }

    public async ValueTask<QueueEnqueueResult> EnqueueAsync(
        ScanQueueMessage message,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(message);
        cancellationToken.ThrowIfCancellationRequested();

        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
        var now = _timeProvider.GetUtcNow();
        await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);

        var attempt = 1;
        var entries = BuildEntries(message, now, attempt);
        var messageId = await AddToStreamAsync(
                db,
                _options.StreamName,
                entries,
                _options.ApproximateMaxLength,
                _options.ApproximateMaxLength is not null)
            .ConfigureAwait(false);

        var idempotencyToken = message.IdempotencyKey ?? message.JobId;
        var idempotencyKey = BuildIdempotencyKey(idempotencyToken);

        var stored = await db.StringSetAsync(
                key: idempotencyKey,
                value: messageId,
                when: When.NotExists,
                expiry: _options.IdempotencyWindow)
            .ConfigureAwait(false);

        if (!stored)
        {
            // Duplicate enqueue – delete the freshly added entry and surface cached ID.
            await db.StreamDeleteAsync(
                    _options.StreamName,
                    new RedisValue[] { messageId })
                .ConfigureAwait(false);

            var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
            var duplicateId = existing.IsNullOrEmpty ? messageId : existing;

            _logger.LogDebug(
                "Duplicate queue enqueue detected for job {JobId} (token {Token}), returning existing stream id {StreamId}.",
                message.JobId,
                idempotencyToken,
                duplicateId.ToString());

            QueueMetrics.RecordDeduplicated(TransportName);
            return new QueueEnqueueResult(duplicateId.ToString()!, true);
        }

        _logger.LogDebug(
            "Enqueued job {JobId} into stream {Stream} with id {StreamId}.",
            message.JobId,
            _options.StreamName,
            messageId.ToString());

        QueueMetrics.RecordEnqueued(TransportName);
        return new QueueEnqueueResult(messageId.ToString()!, false);
    }

    public async ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync(
        QueueLeaseRequest request,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(request);
        cancellationToken.ThrowIfCancellationRequested();

        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
        await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);

        var entries = await db.StreamReadGroupAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                request.Consumer,
                position: ">",
                count: request.BatchSize,
                flags: CommandFlags.None)
            .ConfigureAwait(false);

        if (entries is null || entries.Length == 0)
        {
            return Array.Empty<IScanQueueLease>();
        }

        var now = _timeProvider.GetUtcNow();
        var leases = new List<IScanQueueLease>(entries.Length);

        foreach (var entry in entries)
        {
            var lease = TryMapLease(
                entry,
                request.Consumer,
                now,
                request.LeaseDuration,
                default);

            if (lease is null)
            {
                _logger.LogWarning(
                    "Stream entry {StreamId} is missing required metadata; acknowledging to avoid poison message.",
                    entry.Id.ToString());
                await db.StreamAcknowledgeAsync(
                        _options.StreamName,
                        _options.ConsumerGroup,
                        new RedisValue[] { entry.Id })
                    .ConfigureAwait(false);
                await db.StreamDeleteAsync(
                        _options.StreamName,
                        new RedisValue[] { entry.Id })
                    .ConfigureAwait(false);
                continue;
            }

            leases.Add(lease);
        }

        return leases;
    }

    public async ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync(
        QueueClaimOptions options,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(options);
        cancellationToken.ThrowIfCancellationRequested();

        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
        await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);

        var pending = await db.StreamPendingMessagesAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                options.BatchSize,
                RedisValue.Null,
                (long)options.MinIdleTime.TotalMilliseconds)
            .ConfigureAwait(false);

        if (pending is null || pending.Length == 0)
        {
            return Array.Empty<IScanQueueLease>();
        }

        var eligible = pending
            .Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
            .ToArray();

        if (eligible.Length == 0)
        {
            return Array.Empty<IScanQueueLease>();
        }

        var messageIds = eligible
            .Select(static p => (RedisValue)p.MessageId)
            .ToArray();

        var entries = await db.StreamClaimAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                options.ClaimantConsumer,
                0,
                messageIds,
                CommandFlags.None)
            .ConfigureAwait(false);

        if (entries is null || entries.Length == 0)
        {
            return Array.Empty<IScanQueueLease>();
        }

        var now = _timeProvider.GetUtcNow();
        var pendingById = Enumerable.ToDictionary<StreamPendingMessageInfo, string, StreamPendingMessageInfo>(
            eligible,
            static p => p.MessageId.IsNullOrEmpty ? string.Empty : p.MessageId.ToString(),
            static p => p,
            StringComparer.Ordinal);

        var leases = new List<IScanQueueLease>(entries.Length);
        foreach (var entry in entries)
        {
            var entryIdValue = entry.Id;
            var entryId = entryIdValue.IsNullOrEmpty ? string.Empty : entryIdValue.ToString();
            var hasPending = pendingById.TryGetValue(entryId, out var pendingInfo);
            var attempt = hasPending
                ? (int)Math.Max(1, pendingInfo.DeliveryCount)
                : 1;

            var lease = TryMapLease(
                entry,
                options.ClaimantConsumer,
                now,
                _queueOptions.DefaultLeaseDuration,
                attempt);

            if (lease is null)
            {
                _logger.LogWarning(
                    "Unable to map claimed stream entry {StreamId}; acknowledging to unblock queue.",
                    entry.Id.ToString());
                await db.StreamAcknowledgeAsync(
                        _options.StreamName,
                        _options.ConsumerGroup,
                        new RedisValue[] { entry.Id })
                    .ConfigureAwait(false);
                await db.StreamDeleteAsync(
                        _options.StreamName,
                        new RedisValue[] { entry.Id })
                    .ConfigureAwait(false);
                continue;
            }

            leases.Add(lease);
        }

        return leases;
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;
        if (_connection is not null)
        {
            await _connection.CloseAsync();
            _connection.Dispose();
        }

        _connectionLock.Dispose();
        _groupInitLock.Dispose();
        GC.SuppressFinalize(this);
    }

    internal async Task AcknowledgeAsync(
        RedisScanQueueLease lease,
        CancellationToken cancellationToken)
    {
        if (!lease.TryBeginCompletion())
        {
            return;
        }

        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);

        await db.StreamAcknowledgeAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                new RedisValue[] { lease.MessageId })
            .ConfigureAwait(false);

        await db.StreamDeleteAsync(
                _options.StreamName,
                new RedisValue[] { lease.MessageId })
            .ConfigureAwait(false);

        _logger.LogDebug(
            "Acknowledged job {JobId} ({MessageId}) on consumer {Consumer}.",
            lease.JobId,
            lease.MessageId,
            lease.Consumer);

        QueueMetrics.RecordAck(TransportName);
    }

    internal async Task RenewLeaseAsync(
        RedisScanQueueLease lease,
        TimeSpan leaseDuration,
        CancellationToken cancellationToken)
    {
        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);

        await db.StreamClaimAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                lease.Consumer,
                0,
                new RedisValue[] { lease.MessageId },
                CommandFlags.None)
            .ConfigureAwait(false);

        var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
        lease.RefreshLease(expires);

        _logger.LogDebug(
            "Renewed lease for job {JobId} until {LeaseExpiry:u}.",
            lease.JobId,
            expires);
    }

    internal async Task ReleaseAsync(
        RedisScanQueueLease lease,
        QueueReleaseDisposition disposition,
        CancellationToken cancellationToken)
    {
        if (disposition == QueueReleaseDisposition.Retry
            && lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
        {
            _logger.LogWarning(
                "Job {JobId} reached max delivery attempts ({Attempts}); moving to dead-letter.",
                lease.JobId,
                lease.Attempt);

            await DeadLetterAsync(
                lease,
                $"max-delivery-attempts:{lease.Attempt}",
                cancellationToken).ConfigureAwait(false);
            return;
        }

        if (!lease.TryBeginCompletion())
        {
            return;
        }

        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
        await db.StreamAcknowledgeAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                new RedisValue[] { lease.MessageId })
            .ConfigureAwait(false);
        await db.StreamDeleteAsync(
                _options.StreamName,
                new RedisValue[] { lease.MessageId })
            .ConfigureAwait(false);

        QueueMetrics.RecordAck(TransportName);

        if (disposition == QueueReleaseDisposition.Retry)
        {
            QueueMetrics.RecordRetry(TransportName);

            var delay = CalculateBackoff(lease.Attempt);
            if (delay > TimeSpan.Zero)
            {
                _logger.LogDebug(
                    "Delaying retry for job {JobId} by {Delay} (attempt {Attempt}).",
                    lease.JobId,
                    delay,
                    lease.Attempt);

                try
                {
                    await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
                }
                catch (TaskCanceledException)
                {
                    return;
                }
            }

            var requeueMessage = new ScanQueueMessage(lease.JobId, lease.Payload)
            {
                IdempotencyKey = lease.IdempotencyKey,
                Attributes = lease.Attributes,
                TraceId = null
            };

            var now = _timeProvider.GetUtcNow();
            var entries = BuildEntries(requeueMessage, now, lease.Attempt + 1);

            await AddToStreamAsync(
                    db,
                    _options.StreamName,
                    entries,
                    _options.ApproximateMaxLength,
                    _options.ApproximateMaxLength is not null)
                .ConfigureAwait(false);

            QueueMetrics.RecordEnqueued(TransportName);
            _logger.LogWarning(
                "Released job {JobId} for retry (attempt {Attempt}).",
                lease.JobId,
                lease.Attempt + 1);
        }
        else
        {
            _logger.LogInformation(
                "Abandoned job {JobId} after {Attempt} attempt(s).",
                lease.JobId,
                lease.Attempt);
        }
    }

    internal async Task DeadLetterAsync(
        RedisScanQueueLease lease,
        string reason,
        CancellationToken cancellationToken)
    {
        if (!lease.TryBeginCompletion())
        {
            return;
        }

        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);

        await db.StreamAcknowledgeAsync(
                _options.StreamName,
                _options.ConsumerGroup,
                new RedisValue[] { lease.MessageId })
            .ConfigureAwait(false);

        await db.StreamDeleteAsync(
                _options.StreamName,
                new RedisValue[] { lease.MessageId })
            .ConfigureAwait(false);

        var now = _timeProvider.GetUtcNow();
        var entries = BuildEntries(
            new ScanQueueMessage(lease.JobId, lease.Payload)
            {
                IdempotencyKey = lease.IdempotencyKey,
                Attributes = lease.Attributes,
                TraceId = null
            },
            now,
            lease.Attempt);

        await AddToStreamAsync(
                db,
                _queueOptions.DeadLetter.StreamName,
                entries,
                null,
                false)
            .ConfigureAwait(false);

        _logger.LogError(
            "Dead-lettered job {JobId} (attempt {Attempt}): {Reason}",
            lease.JobId,
            lease.Attempt,
            reason);

        QueueMetrics.RecordDeadLetter(TransportName);
    }

    private async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
    {
        if (_connection is not null)
        {
            return _connection.GetDatabase(_options.Database ?? -1);
        }

        await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_connection is null)
            {
                var config = ConfigurationOptions.Parse(_options.ConnectionString!);
                config.AbortOnConnectFail = false;
                config.ConnectTimeout = (int)_options.InitializationTimeout.TotalMilliseconds;
                config.ConnectRetry = 3;
                if (_options.Database is not null)
                {
                    config.DefaultDatabase = _options.Database;
                }

                _connection = await _connectionFactory(config).ConfigureAwait(false);
            }

            return _connection.GetDatabase(_options.Database ?? -1);
        }
        finally
        {
            _connectionLock.Release();
        }
    }

    private async Task EnsureConsumerGroupAsync(
        IDatabase database,
        CancellationToken cancellationToken)
    {
        if (_groupInitialized)
        {
            return;
        }

        await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            if (_groupInitialized)
            {
                return;
            }

            try
            {
                await database.StreamCreateConsumerGroupAsync(
                        _options.StreamName,
                        _options.ConsumerGroup,
                        StreamPosition.Beginning,
                        createStream: true)
                    .ConfigureAwait(false);
            }
            catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
            {
                // Already exists.
            }

            _groupInitialized = true;
        }
        finally
        {
            _groupInitLock.Release();
        }
    }

    private NameValueEntry[] BuildEntries(
        ScanQueueMessage message,
        DateTimeOffset enqueuedAt,
        int attempt)
    {
        var attributeCount = message.Attributes?.Count ?? 0;
        var entries = ArrayPool<NameValueEntry>.Shared.Rent(6 + attributeCount);
        var index = 0;

        entries[index++] = new NameValueEntry(QueueEnvelopeFields.JobId, message.JobId);
        entries[index++] = new NameValueEntry(QueueEnvelopeFields.Attempt, attempt);
        entries[index++] = new NameValueEntry(QueueEnvelopeFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
        entries[index++] = new NameValueEntry(
            QueueEnvelopeFields.IdempotencyKey,
            message.IdempotencyKey ?? message.JobId);
        entries[index++] = new NameValueEntry(
            QueueEnvelopeFields.Payload,
            message.Payload.ToArray());
        entries[index++] = new NameValueEntry(
            QueueEnvelopeFields.TraceId,
            message.TraceId ?? string.Empty);

        if (attributeCount > 0)
        {
            foreach (var kvp in message.Attributes!)
            {
                entries[index++] = new NameValueEntry(
                    QueueEnvelopeFields.AttributePrefix + kvp.Key,
                    kvp.Value);
            }
        }

        var result = entries.AsSpan(0, index).ToArray();
        ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true);
        return result;
    }

    private RedisScanQueueLease? TryMapLease(
        StreamEntry entry,
        string consumer,
        DateTimeOffset now,
        TimeSpan leaseDuration,
        int? attemptOverride)
    {
        if (entry.Values is null || entry.Values.Length == 0)
        {
            return null;
        }

        string? jobId = null;
        string? idempotency = null;
        long? enqueuedAtUnix = null;
        byte[]? payload = null;
        string? traceId = null;
        var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
        var attempt = attemptOverride ?? 1;

        foreach (var field in entry.Values)
        {
            var name = field.Name.ToString();
            if (name.Equals(QueueEnvelopeFields.JobId, StringComparison.Ordinal))
            {
                jobId = field.Value.ToString();
            }
            else if (name.Equals(QueueEnvelopeFields.IdempotencyKey, StringComparison.Ordinal))
            {
                idempotency = field.Value.ToString();
            }
            else if (name.Equals(QueueEnvelopeFields.EnqueuedAt, StringComparison.Ordinal))
            {
                if (long.TryParse(field.Value.ToString(), out var unix))
                {
                    enqueuedAtUnix = unix;
                }
            }
            else if (name.Equals(QueueEnvelopeFields.Payload, StringComparison.Ordinal))
            {
                payload = (byte[]?)field.Value ?? Array.Empty<byte>();
            }
            else if (name.Equals(QueueEnvelopeFields.Attempt, StringComparison.Ordinal))
            {
                if (int.TryParse(field.Value.ToString(), out var parsedAttempt))
                {
                    attempt = Math.Max(parsedAttempt, attempt);
                }
            }
            else if (name.Equals(QueueEnvelopeFields.TraceId, StringComparison.Ordinal))
            {
                traceId = field.Value.ToString();
            }
            else if (name.StartsWith(QueueEnvelopeFields.AttributePrefix, StringComparison.Ordinal))
            {
                attributes[name[QueueEnvelopeFields.AttributePrefix.Length..]] = field.Value.ToString();
            }
        }

        if (jobId is null || payload is null || enqueuedAtUnix is null)
        {
            return null;
        }

        var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
        var leaseExpires = now.Add(leaseDuration);

        var attributeView = attributes.Count == 0
            ? EmptyReadOnlyDictionary<string, string>.Instance
            : new ReadOnlyDictionary<string, string>(attributes);

        return new RedisScanQueueLease(
            this,
            entry.Id.ToString(),
            jobId,
            payload,
            attempt,
            enqueuedAt,
            leaseExpires,
            consumer,
            idempotency,
            attributeView);
    }

    private TimeSpan CalculateBackoff(int attempt)
    {
        var configuredInitial = _options.RetryInitialBackoff > TimeSpan.Zero
            ? _options.RetryInitialBackoff
            : _queueOptions.RetryInitialBackoff;

        var initial = configuredInitial > TimeSpan.Zero
            ? configuredInitial
            : TimeSpan.Zero;

        if (initial <= TimeSpan.Zero)
        {
            return TimeSpan.Zero;
        }

        if (attempt <= 1)
        {
            return initial;
        }

        var configuredMax = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
            ? _queueOptions.RetryMaxBackoff
            : initial;

        var max = configuredMax <= TimeSpan.Zero
            ? initial
            : configuredMax;

        var exponent = attempt - 1;
        var scale = Math.Pow(2, exponent - 1);
        var scaledTicks = initial.Ticks * scale;
        var cappedTicks = Math.Min(max.Ticks, scaledTicks);

        var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
        return TimeSpan.FromTicks(resultTicks);
    }

    private async Task<RedisValue> AddToStreamAsync(
        IDatabase database,
        RedisKey stream,
        NameValueEntry[] entries,
        int? maxLength,
        bool useApproximateLength)
    {
        var capacity = 4 + (entries.Length * 2);
        var args = new List<object>(capacity)
        {
            stream
        };

        if (maxLength.HasValue)
        {
            args.Add("MAXLEN");
            if (useApproximateLength)
            {
                args.Add("~");
            }

            args.Add(maxLength.Value);
        }

        args.Add("*");
        for (var i = 0; i < entries.Length; i++)
        {
            args.Add(entries[i].Name);
            args.Add(entries[i].Value);
        }

        var result = await database.ExecuteAsync("XADD", args.ToArray()).ConfigureAwait(false);
        return (RedisValue)result!;
    }

    private static class EmptyReadOnlyDictionary<TKey, TValue>
        where TKey : notnull
    {
        public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
            new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
    }

    internal async ValueTask PingAsync(CancellationToken cancellationToken)
    {
        var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
        await db.ExecuteAsync("PING").ConfigureAwait(false);
    }
}
