using System.Text.Json;
using Serilog;
using ZB.MOM.WW.LocalDb;

namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;

/// <summary>
///     Durable queue in the node's consolidated LocalDb absorbs every qualifying alarm event, a
///     drain worker batches rows to the historian gateway via <see cref="IAlarmHistorianWriter"/>
///     on an exponential-backoff cadence, and operator acks never block on the historian being
///     reachable.
/// </summary>
/// <remarks>
///     <para>
///         Rows live in <c>alarm_sf_events</c> (see <see cref="AlarmSfSchema"/>), which the host
///         registers for replication. That is the point of this type living on
///         <see cref="ILocalDb"/> rather than owning its own file: the buffer mirrors to the
///         redundant pair peer, so a node that dies holding undelivered alarm history no longer
///         takes it to the grave.
///     </para>
///     <para>
///         <b>Only one node of a pair may drain.</b> Replication puts the Primary's queued rows in
///         the Secondary's table too; an ungated drain there would re-deliver every event,
///         continuously. The <c>drainGate</c> constructor argument is how the caller scopes
///         draining to the Primary. Enqueue is gated separately and upstream, by
///         <c>HistorianAdapterActor</c>.
///     </para>
///     <para>
///         Dead-lettered rows stay in place for the configured retention window (default 30 days)
///         so operators can inspect + manually retry before the sweeper purges them. Regular queue
///         capacity is bounded — overflow evicts the oldest non-dead-lettered rows with a WARN log.
///         The durability guarantee is therefore bounded by <see cref="DefaultCapacity"/>: under a
///         sustained historian outage, accepted events may be evicted before delivery. The
///         <see cref="HistorianSinkStatus.EvictedCount"/> counter makes overflow visible to
///         operators without requiring the WARN log to be scraped.
///     </para>
///     <para>
///         Drain runs on a self-rescheduling one-shot <see cref="System.Threading.Timer"/>.
///         Exponential backoff on <see cref="HistorianWriteOutcome.RetryPlease"/>:
///         1s → 2s → 5s → 15s → 60s cap — the backoff is applied to the timer's next due-time, so a
///         historian outage genuinely slows the drain cadence.
///         <see cref="HistorianWriteOutcome.PermanentFail"/> rows flip the <c>dead_lettered</c> flag
///         on the individual row; neighbors in the batch still retry on their own cadence.
///     </para>
/// </remarks>
public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposable
{
    /// <summary>Default queue capacity — oldest non-dead-lettered rows evicted past this.</summary>
    public const long DefaultCapacity = 1_000_000;

    /// <summary>Default window dead-lettered rows are retained for before the sweeper purges them.</summary>
    public static readonly TimeSpan DefaultDeadLetterRetention = TimeSpan.FromDays(30);

    /// <summary>Default max delivery attempts before a perpetually-retrying (poison) row is dead-lettered.</summary>
    public const int DefaultMaxAttempts = 10;

    private static readonly TimeSpan[] BackoffLadder =
    [
        TimeSpan.FromSeconds(1),
        TimeSpan.FromSeconds(2),
        TimeSpan.FromSeconds(5),
        TimeSpan.FromSeconds(15),
        TimeSpan.FromSeconds(60),
    ];

    private readonly ILocalDb _db;
    private readonly IAlarmHistorianWriter _writer;
    private readonly ILogger _logger;
    private readonly int _batchSize;
    private readonly long _capacity;
    private readonly TimeSpan _deadLetterRetention;
    private readonly int _maxAttempts;
    private readonly Func<DateTime> _clock;
    private readonly Func<bool> _drainGate;

    private readonly SemaphoreSlim _drainGateLock = new(1, 1);
    private Timer? _drainTimer;
    private TimeSpan _tickInterval;
    private volatile int _backoffIndex;
    // volatile: read by EnqueueAsync, DrainOnceAsync, RescheduleDrain, and StartDrainLoop
    // from different threads; Dispose() writes it from the owner's thread.
    private volatile bool _disposed;

    private readonly object _statusLock = new();
    private DateTime? _lastDrainUtc;
    private DateTime? _lastSuccessUtc;
    private string? _lastError;
    private HistorianDrainState _drainState = HistorianDrainState.Idle;
    private long _evictedCount;
    // Tracks the last gate decision so a denial is logged on the transition rather than on every
    // tick. Null until the first drain runs.
    private bool? _lastGateDecision;

    private long _queuedRowCount;
    // Probe counter — incremented every time we actually issue a real COUNT(*) for
    // capacity enforcement. Public for test instrumentation only.
    private long _capacityProbeCount;
    // After every Nth enqueue we resync the in-memory counter from storage to defend
    // against silent drift (e.g. the peer replicating rows in underneath us).
    private const long ResyncEnqueueInterval = 10_000;
    private long _enqueuesSinceResync;

    /// <summary>Test-only: number of times the perf-optimised path fell through to a real <c>COUNT(*)</c>.</summary>
    public long DebugCapacityProbeCount => Interlocked.Read(ref _capacityProbeCount);

    /// <summary>
    ///     Initializes a new instance of the <see cref="LocalDbStoreAndForwardSink"/> class.
    /// </summary>
    /// <param name="db">
    ///     The node's local database. Its <c>alarm_sf_events</c> table must already exist and be
    ///     registered for replication — the host does both in <c>LocalDbSetup.OnReady</c>, before
    ///     any consumer can resolve this sink.
    /// </param>
    /// <param name="writer">The alarm historian writer to handle batch forwarding.</param>
    /// <param name="logger">The logger for diagnostic output.</param>
    /// <param name="batchSize">The maximum number of rows to forward in a single batch. Defaults to 100.</param>
    /// <param name="capacity">The maximum queue capacity before evicting oldest rows. Defaults to 1,000,000.</param>
    /// <param name="deadLetterRetention">The timespan to retain dead-lettered rows before purging. Defaults to 30 days.</param>
    /// <param name="maxAttempts">The maximum number of delivery attempts before a perpetually-retrying (poison) row is dead-lettered. Defaults to 10.</param>
    /// <param name="clock">Optional clock function for testing; defaults to <see cref="DateTime.UtcNow"/>.</param>
    /// <param name="drainGate">
    ///     Consulted at the top of every drain tick; <c>false</c> skips the tick entirely, leaving
    ///     the queue intact. Callers running a redundant pair MUST supply a gate that is true on at
    ///     most one node, because the queue replicates — see the class remarks. The default
    ///     (<c>null</c> ⇒ always drain) is the correct single-node and test posture.
    /// </param>
    public LocalDbStoreAndForwardSink(
        ILocalDb db,
        IAlarmHistorianWriter writer,
        ILogger logger,
        int batchSize = 100,
        long capacity = DefaultCapacity,
        TimeSpan? deadLetterRetention = null,
        int maxAttempts = DefaultMaxAttempts,
        Func<DateTime>? clock = null,
        Func<bool>? drainGate = null)
    {
        _db = db ?? throw new ArgumentNullException(nameof(db));
        _writer = writer ?? throw new ArgumentNullException(nameof(writer));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _batchSize = batchSize > 0 ? batchSize : throw new ArgumentOutOfRangeException(nameof(batchSize));
        _capacity = capacity > 0 ? capacity : throw new ArgumentOutOfRangeException(nameof(capacity));
        _deadLetterRetention = deadLetterRetention ?? DefaultDeadLetterRetention;
        _maxAttempts = maxAttempts > 0 ? maxAttempts : throw new ArgumentOutOfRangeException(nameof(maxAttempts));
        _clock = clock ?? (() => DateTime.UtcNow);
        _drainGate = drainGate ?? (static () => true);

        _queuedRowCount = ProbeQueuedRowCount();
    }

    /// <summary>
    ///     Start the background drain worker. Not started automatically so tests can
    ///     drive <see cref="DrainOnceAsync"/> deterministically.
    /// </summary>
    /// <remarks>
    ///     The worker is a self-rescheduling one-shot <see cref="Timer"/>: after each
    ///     drain it sets its next due-time to <c>max(tickInterval, CurrentBackoff)</c>
    ///     so a historian outage actually slows the cadence down the backoff ladder.
    ///     The callback body is fully guarded — a fault in
    ///     <see cref="DrainOnceAsync"/> is logged and recorded into
    ///     <see cref="GetStatus"/> rather than being lost as an unobserved task
    ///     exception.
    /// </remarks>
    /// <param name="tickInterval">The base interval between drain attempts.</param>
    public void StartDrainLoop(TimeSpan tickInterval)
    {
        if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink));
        _tickInterval = tickInterval;
        _drainTimer?.Dispose();
        // One-shot: dueTime = tickInterval, period = Infinite. RescheduleDrain re-arms
        // it after every tick once the backoff-aware delay is known.
        _drainTimer = new Timer(DrainTimerCallback, null, tickInterval, Timeout.InfiniteTimeSpan);
    }

    private async void DrainTimerCallback(object? _)
    {
        try
        {
            await DrainOnceAsync(CancellationToken.None).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            // Without this catch the fault would be an unobserved exception on an
            // async-void timer callback — never logged, never surfaced. Record it
            // so the Admin UI / health check sees the stalled drain.
            lock (_statusLock)
            {
                _lastError = ex.Message;
                _drainState = HistorianDrainState.BackingOff;
            }
            _logger.Error(ex, "Historian drain tick faulted; will retry on next tick");
        }
        finally
        {
            RescheduleDrain();
        }
    }

    /// <summary>Re-arm the one-shot drain timer honoring the current backoff window.</summary>
    private void RescheduleDrain()
    {
        if (_disposed) return;
        HistorianDrainState state;
        lock (_statusLock) { state = _drainState; }
        // While backing off, wait out the full ladder delay; otherwise the steady
        // tick cadence. Never faster than tickInterval.
        var delay = state == HistorianDrainState.BackingOff
            ? (CurrentBackoff > _tickInterval ? CurrentBackoff : _tickInterval)
            : _tickInterval;
        try { _drainTimer?.Change(delay, Timeout.InfiniteTimeSpan); }
        catch (ObjectDisposedException) { /* raced with Dispose — nothing to re-arm */ }
    }

    /// <inheritdoc />
    public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
    {
        if (evt is null) throw new ArgumentNullException(nameof(evt));
        if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink));

        await EnforceCapacityFastPathAsync(cancellationToken).ConfigureAwait(false);

        var payload = JsonSerializer.Serialize(evt);

        // ON CONFLICT DO NOTHING: re-enqueuing an identical event (the pair's boot-window
        // double-accept) must not resurrect a row the drain already bumped or dead-lettered.
        var inserted = await _db.ExecuteAsync(
            """
            INSERT INTO alarm_sf_events
                (id, alarm_id, enqueued_at_utc, payload_json, attempt_count)
            VALUES (@Id, @AlarmId, @EnqueuedAtUtc, @PayloadJson, 0)
            ON CONFLICT(id) DO NOTHING
            """,
            new
            {
                Id = AlarmSfSchema.DeriveId(payload),
                AlarmId = evt.AlarmId,
                EnqueuedAtUtc = _clock().ToString("O"),
                PayloadJson = payload,
            },
            cancellationToken).ConfigureAwait(false);

        if (inserted > 0) Interlocked.Increment(ref _queuedRowCount);
    }

    /// <summary>
    ///     Capacity enforcement on the hot enqueue path: consults the in-memory counter
    ///     first and only probes storage with a real <c>COUNT(*)</c> when (a) the
    ///     cached value indicates the capacity wall is in reach, or (b) the periodic
    ///     resync interval has elapsed. The actual eviction (when over capacity) goes
    ///     through <see cref="EnforceCapacityAsync"/> which still runs a precise
    ///     COUNT to compute the exact number of rows to evict.
    /// </summary>
    private async Task EnforceCapacityFastPathAsync(CancellationToken ct)
    {
        var enqueuesSinceResync = Interlocked.Increment(ref _enqueuesSinceResync);
        var cached = Interlocked.Read(ref _queuedRowCount);

        // Periodic resync — bounded amount of drift even under exotic conditions. Now doubly
        // warranted: replication applies the peer's rows straight into the table, so the counter
        // has a second way to fall behind that no local code path can observe.
        if (enqueuesSinceResync >= ResyncEnqueueInterval)
        {
            await ResyncQueuedRowCountAsync(ct).ConfigureAwait(false);
            cached = Interlocked.Read(ref _queuedRowCount);
            Interlocked.Exchange(ref _enqueuesSinceResync, 0);
        }

        // Below capacity per the cached counter — skip the COUNT(*) entirely.
        if (cached < _capacity) return;

        // Cached counter says we're at or above the capacity wall — fall back to the
        // precise path which probes COUNT(*) and evicts whatever's needed.
        await EnforceCapacityAsync(ct).ConfigureAwait(false);
    }

    /// <summary>Synchronously query <c>COUNT(*)</c> of non-dead-lettered rows. Used at startup.</summary>
    private long ProbeQueuedRowCount()
    {
        Interlocked.Increment(ref _capacityProbeCount);
        using var conn = _db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0";
        return (long)(cmd.ExecuteScalar() ?? 0L);
    }

    /// <summary>Re-sync the in-memory counter from storage (async path).</summary>
    private async Task ResyncQueuedRowCountAsync(CancellationToken ct)
    {
        Interlocked.Increment(ref _capacityProbeCount);
        var live = await CountAsync("dead_lettered = 0", ct).ConfigureAwait(false);
        Interlocked.Exchange(ref _queuedRowCount, live);
    }

    private async Task<long> CountAsync(string predicate, CancellationToken ct)
    {
        var rows = await _db.QueryAsync(
            $"SELECT COUNT(*) FROM alarm_sf_events WHERE {predicate}",
            r => r.GetInt64(0),
            parameters: null,
            ct).ConfigureAwait(false);
        return rows.Count > 0 ? rows[0] : 0L;
    }

    /// <summary>
    ///     Read up to <see cref="_batchSize"/> queued rows, forward through the writer,
    ///     remove Ack'd rows, dead-letter PermanentFail rows, and extend the backoff
    ///     on RetryPlease. Safe to call from multiple threads; the semaphore enforces
    ///     serial execution.
    /// </summary>
    /// <remarks>
    ///     Returns without touching the queue when the drain gate is closed. On a redundant pair
    ///     that is the Secondary's steady state: its table fills by replication and stays put until
    ///     it is promoted.
    /// </remarks>
    /// <param name="ct">Cancellation token for the operation.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task DrainOnceAsync(CancellationToken ct)
    {
        if (_disposed) return;
        if (!await _drainGateLock.WaitAsync(0, ct).ConfigureAwait(false)) return;
        try
        {
            if (!EvaluateDrainGate())
            {
                lock (_statusLock) { _drainState = HistorianDrainState.NotPrimary; }
                return;
            }

            lock (_statusLock)
            {
                _drainState = HistorianDrainState.Draining;
                _lastDrainUtc = _clock();
            }

            await PurgeAgedDeadLettersAsync(ct).ConfigureAwait(false);
            var batch = await ReadBatchAsync(ct).ConfigureAwait(false);
            if (batch.Count == 0)
            {
                lock (_statusLock) { _drainState = HistorianDrainState.Idle; }
                return;
            }

            // A null/un-deserializable payload can never succeed — dead-letter it
            // immediately for its own id so it cannot stall the queue head, and
            // exclude it from the batch handed to the writer.
            var corruptIds = batch.Where(r => r.Event is null).Select(r => r.Id).ToList();
            var liveRows = batch.Where(r => r.Event is not null).ToList();
            var events = liveRows.Select(r => r.Event!).ToList();

            if (corruptIds.Count > 0)
            {
                await using (var corruptTx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false))
                {
                    foreach (var id in corruptIds)
                        await DeadLetterRowAsync(corruptTx, id, $"corrupt payload at {_clock():O}", ct).ConfigureAwait(false);
                    await corruptTx.CommitAsync(ct).ConfigureAwait(false);
                }
                Interlocked.Add(ref _queuedRowCount, -corruptIds.Count);
                _logger.Warning(
                    "Dead-lettered {Count} historian queue row(s) with un-deserializable payload",
                    corruptIds.Count);
            }

            if (events.Count == 0)
            {
                lock (_statusLock) { _drainState = HistorianDrainState.Idle; }
                return;
            }

            IReadOnlyList<HistorianWriteOutcome> outcomes;
            try
            {
                outcomes = await _writer.WriteBatchAsync(events, ct).ConfigureAwait(false);
                lock (_statusLock) { _lastError = null; }
            }
            catch (OperationCanceledException)
            {
                lock (_statusLock) { _drainState = HistorianDrainState.BackingOff; }
                throw;
            }
            catch (Exception ex)
            {
                // Writer-side exception — treat entire batch as RetryPlease.
                lock (_statusLock)
                {
                    _lastError = ex.Message;
                    _drainState = HistorianDrainState.BackingOff;
                }
                _logger.Warning(ex, "Historian writer threw on batch of {Count}; deferring retry", events.Count);
                BumpBackoff();
                return;
            }

            if (outcomes.Count != events.Count)
            {
                var msg = $"Writer returned {outcomes.Count} outcomes for {events.Count} events — expected 1:1; treating as batch retry";
                lock (_statusLock)
                {
                    _lastError = msg;
                    _drainState = HistorianDrainState.BackingOff;
                }
                _logger.Warning("Historian writer contract violation: {Msg}", msg);
                BumpBackoff();
                return;
            }

            int rowsLeavingQueue = 0;
            await using (var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false))
            {
                for (var i = 0; i < outcomes.Count; i++)
                {
                    var outcome = outcomes[i];
                    var id = liveRows[i].Id;
                    switch (outcome)
                    {
                        case HistorianWriteOutcome.Ack:
                            // Deleted, not marked delivered. The replication engine carries the
                            // delete as a tombstone, so the peer drops its copy and cannot
                            // re-deliver the row after a failover.
                            await DeleteRowAsync(tx, id, ct).ConfigureAwait(false);
                            rowsLeavingQueue++;
                            break;
                        case HistorianWriteOutcome.PermanentFail:
                            await DeadLetterRowAsync(tx, id, $"permanent fail at {_clock():O}", ct).ConfigureAwait(false);
                            rowsLeavingQueue++;
                            break;
                        case HistorianWriteOutcome.RetryPlease:
                            // finding 002: cap retries so a perpetually-RetryPlease (poison)
                            // row cannot retry forever at the 60s backoff floor. The incoming
                            // attempt count is the count BEFORE this attempt; +1 accounts for the
                            // bump this drain represents. At the cap, dead-letter instead of
                            // bumping — and count it as leaving the live queue like PermanentFail.
                            if (liveRows[i].AttemptCount + 1 >= _maxAttempts)
                            {
                                await DeadLetterRowAsync(tx, id, $"max attempts ({_maxAttempts}) exceeded", ct).ConfigureAwait(false);
                                rowsLeavingQueue++;
                            }
                            else
                            {
                                await BumpAttemptAsync(tx, id, "retry-please", ct).ConfigureAwait(false);
                            }
                            break;
                    }
                }
                await tx.CommitAsync(ct).ConfigureAwait(false);
            }
            // Ack-deleted + PermanentFail-dead-lettered rows both leave the
            // non-dead-lettered queue, as do RetryPlease rows that hit the max-attempts
            // cap (finding 002) — keep the counter aligned.
            if (rowsLeavingQueue > 0)
                Interlocked.Add(ref _queuedRowCount, -rowsLeavingQueue);

            var acks = outcomes.Count(o => o == HistorianWriteOutcome.Ack);
            lock (_statusLock)
            {
                if (acks > 0) _lastSuccessUtc = _clock();

                if (outcomes.Any(o => o == HistorianWriteOutcome.RetryPlease))
                    _drainState = HistorianDrainState.BackingOff;
                else
                    _drainState = HistorianDrainState.Idle;
            }

            if (outcomes.Any(o => o == HistorianWriteOutcome.RetryPlease))
                BumpBackoff();
            else
                ResetBackoff();
        }
        finally
        {
            _drainGateLock.Release();
        }
    }

    /// <summary>
    ///     Consults the injected gate, logging only when the decision changes.
    /// </summary>
    /// <remarks>
    ///     Logged at all because the closed state is indistinguishable from a healthy idle queue
    ///     from the outside, and one way to reach it is a redundancy snapshot that never names this
    ///     node — the documented identity-mismatch shape. Without this line, that misconfiguration
    ///     would present as alarm history quietly ceasing on both nodes of a pair. Logged on the
    ///     transition rather than per tick because the drain runs every few seconds.
    /// </remarks>
    /// <returns><c>true</c> when this tick may proceed.</returns>
    private bool EvaluateDrainGate()
    {
        bool open;
        try
        {
            open = _drainGate();
        }
        catch (Exception ex)
        {
            // A throwing gate must not wedge the queue silently, and must not be treated as
            // permission either: skip this tick, say why, and re-ask on the next one.
            _logger.Error(ex, "Historian drain gate threw; skipping this tick");
            return false;
        }

        bool? previous;
        lock (_statusLock)
        {
            previous = _lastGateDecision;
            _lastGateDecision = open;
        }

        if (previous == open) return open;

        if (open)
            _logger.Information("Historian drain resumed — this node now services the alarm queue");
        else
            _logger.Information(
                "Historian drain suspended — this node is not the Primary. Queued alarm events are retained and will drain from whichever node holds the Primary role");

        return open;
    }

    /// <inheritdoc />
    public HistorianSinkStatus GetStatus()
    {
        if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink));
        var queued = Interlocked.Read(ref _queuedRowCount);
        if (queued < 0) queued = 0;

        long deadlettered;
        using (var conn = _db.CreateConnection())
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 1";
            deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
        }

        DateTime? lastDrain, lastSuccess;
        string? lastError;
        HistorianDrainState drainState;
        long evicted;
        lock (_statusLock)
        {
            lastDrain = _lastDrainUtc;
            lastSuccess = _lastSuccessUtc;
            lastError = _lastError;
            drainState = _drainState;
            evicted = _evictedCount;
        }

        return new HistorianSinkStatus(
            QueueDepth: queued,
            DeadLetterDepth: deadlettered,
            LastDrainUtc: lastDrain,
            LastSuccessUtc: lastSuccess,
            LastError: lastError,
            DrainState: drainState,
            EvictedCount: evicted);
    }

    /// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
    /// <returns>The number of rows revived from the dead-letter state.</returns>
    public int RetryDeadLettered()
    {
        if (_disposed) throw new ObjectDisposedException(nameof(LocalDbStoreAndForwardSink));
        // Through a LocalDb connection, not a private one: the capture triggers are what make this
        // revival replicate to the peer, and they only exist on the registered table.
        using var conn = _db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText =
            "UPDATE alarm_sf_events SET dead_lettered = 0, attempt_count = 0, last_error = NULL WHERE dead_lettered = 1";
        var revived = cmd.ExecuteNonQuery();
        // Dead-lettered rows rejoin the non-dead-lettered queue — keep the in-memory
        // counter aligned.
        if (revived > 0) Interlocked.Add(ref _queuedRowCount, revived);
        return revived;
    }

    /// <summary>
    ///     One queued row paired with its deserialized event. <see cref="Event"/> is
    ///     <c>null</c> when the row's <c>payload_json</c> is corrupt or un-deserializable —
    ///     the <see cref="Id"/> always stays bound to its own row so outcomes can
    ///     never be mapped to the wrong row.
    /// </summary>
    private readonly record struct QueueRow(string Id, AlarmHistorianEvent? Event, long AttemptCount);

    private async Task<List<QueueRow>> ReadBatchAsync(CancellationToken ct)
    {
        // Ordered by enqueue time rather than insertion order: a hashed TEXT primary key carries
        // no sequence the way the legacy AUTOINCREMENT rowid did. Round-trip ("O") timestamps sort
        // lexicographically in chronological order; id makes the ordering total.
        var raw = await _db.QueryAsync(
            """
            SELECT id, payload_json, attempt_count FROM alarm_sf_events
            WHERE dead_lettered = 0
            ORDER BY enqueued_at_utc ASC, id ASC
            LIMIT @Limit
            """,
            r => (Id: r.GetString(0), Payload: r.GetString(1), AttemptCount: r.GetInt64(2)),
            new { Limit = _batchSize },
            ct).ConfigureAwait(false);

        var rows = new List<QueueRow>(raw.Count);
        foreach (var (id, payload, attemptCount) in raw)
        {
            AlarmHistorianEvent? evt;
            try
            {
                evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
            }
            catch (JsonException)
            {
                // Malformed JSON — carry a null event so the caller dead-letters this row.
                evt = null;
            }
            rows.Add(new QueueRow(id, evt, attemptCount));
        }
        return rows;
    }

    private static Task DeleteRowAsync(ILocalDbTransaction tx, string id, CancellationToken ct) =>
        tx.ExecuteAsync("DELETE FROM alarm_sf_events WHERE id = @Id", new { Id = id }, ct);

    private Task DeadLetterRowAsync(ILocalDbTransaction tx, string id, string reason, CancellationToken ct) =>
        tx.ExecuteAsync(
            """
            UPDATE alarm_sf_events
            SET dead_lettered = 1, last_attempt_utc = @Now, last_error = @Err,
                attempt_count = attempt_count + 1
            WHERE id = @Id
            """,
            new { Now = _clock().ToString("O"), Err = reason, Id = id },
            ct);

    private Task BumpAttemptAsync(ILocalDbTransaction tx, string id, string reason, CancellationToken ct) =>
        tx.ExecuteAsync(
            """
            UPDATE alarm_sf_events
            SET last_attempt_utc = @Now, last_error = @Err, attempt_count = attempt_count + 1
            WHERE id = @Id
            """,
            new { Now = _clock().ToString("O"), Err = reason, Id = id },
            ct);

    private async Task EnforceCapacityAsync(CancellationToken ct)
    {
        Interlocked.Increment(ref _capacityProbeCount);
        var count = await CountAsync("dead_lettered = 0", ct).ConfigureAwait(false);

        // Resync the in-memory counter while we have a fresh number.
        Interlocked.Exchange(ref _queuedRowCount, count);
        if (count < _capacity) return;

        var toEvict = count - _capacity + 1;
        await _db.ExecuteAsync(
            """
            DELETE FROM alarm_sf_events
            WHERE id IN (
                SELECT id FROM alarm_sf_events
                WHERE dead_lettered = 0
                ORDER BY enqueued_at_utc ASC, id ASC
                LIMIT @N
            )
            """,
            new { N = toEvict },
            ct).ConfigureAwait(false);

        Interlocked.Add(ref _queuedRowCount, -toEvict);
        long lifetimeEvicted;
        lock (_statusLock) { _evictedCount += toEvict; lifetimeEvicted = _evictedCount; }
        _logger.Warning(
            "Historian queue at capacity {Cap} — evicted {Count} oldest row(s) to make room (lifetime evictions: {Total})",
            _capacity, toEvict, lifetimeEvicted);
    }

    private async Task PurgeAgedDeadLettersAsync(CancellationToken ct)
    {
        var cutoff = (_clock() - _deadLetterRetention).ToString("O");
        var purged = await _db.ExecuteAsync(
            """
            DELETE FROM alarm_sf_events
            WHERE dead_lettered = 1 AND last_attempt_utc IS NOT NULL AND last_attempt_utc < @Cutoff
            """,
            new { Cutoff = cutoff },
            ct).ConfigureAwait(false);

        if (purged > 0)
            _logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged);
    }

    private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1);
    private void ResetBackoff() => _backoffIndex = 0;

    /// <summary>Gets the current exponential backoff delay for retry operations.</summary>
    public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex];

    /// <summary>Disposes the sink and releases all held resources including the drain timer and the writer.</summary>
    /// <remarks>
    ///     The <see cref="ILocalDb"/> is NOT disposed here — it is a shared singleton owned by the
    ///     host and used by the deployment cache too.
    /// </remarks>
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        _drainTimer?.Dispose();
        _drainGateLock.Dispose();
        if (_writer is IDisposable writerDisposable) writerDisposable.Dispose();
    }
}
