using System.Text.Json;
using Microsoft.Data.Sqlite;
using Serilog;

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

/// <summary>
///     Phase 7 plan decisions #16–#17 implementation: durable SQLite queue on the node
///     absorbs every qualifying alarm event, a drain worker batches rows to the
///     Wonderware historian sidecar via <see cref="IAlarmHistorianWriter"/> on an
///     exponential-backoff cadence, and operator acks never block on the historian
///     being reachable.
/// </summary>
/// <remarks>
///     <para>
///         Queue schema:
///         <code>
///         CREATE TABLE Queue (
///             RowId INTEGER PRIMARY KEY AUTOINCREMENT,
///             AlarmId TEXT NOT NULL,
///             EnqueuedUtc TEXT NOT NULL,
///             PayloadJson TEXT NOT NULL,
///             AttemptCount INTEGER NOT NULL DEFAULT 0,
///             LastAttemptUtc TEXT NULL,
///             LastError TEXT NULL,
///             DeadLettered INTEGER NOT NULL DEFAULT 0
///         );
///         </code>
///         Dead-lettered rows stay in place for the configured retention window (default
///         30 days per Phase 7 plan decision #21) 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>DeadLettered</c> flag on the individual row; neighbors in the batch
///         still retry on their own cadence.
///     </para>
/// </remarks>
public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
{
    /// <summary>Default queue capacity — oldest non-dead-lettered rows evicted past this.</summary>
    public const long DefaultCapacity = 1_000_000;
    public static readonly TimeSpan DefaultDeadLetterRetention = TimeSpan.FromDays(30);

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

    private readonly string _connectionString;
    private readonly IAlarmHistorianWriter _writer;
    private readonly ILogger _logger;
    private readonly int _batchSize;
    private readonly long _capacity;
    private readonly TimeSpan _deadLetterRetention;
    private readonly Func<DateTime> _clock;

    private readonly SemaphoreSlim _drainGate = new(1, 1);
    private Timer? _drainTimer;
    private TimeSpan _tickInterval;
    private int _backoffIndex;
    private bool _disposed;

    // Core.AlarmHistorian-005: status fields written by the drain timer thread and
    // read concurrently by GetStatus() / health-check threads. Guard all reads and
    // writes with this lock so the Admin UI never observes a torn or stale value.
    private readonly object _statusLock = new();
    private DateTime? _lastDrainUtc;
    private DateTime? _lastSuccessUtc;
    private string? _lastError;
    private HistorianDrainState _drainState = HistorianDrainState.Idle;
    // Core.AlarmHistorian-009: lifetime counter of rows evicted due to capacity overflow.
    // Surfaces in HistorianSinkStatus so operators can see data-loss events without
    // having to scrape the WARN log.
    private long _evictedCount;

    // Core.AlarmHistorian-008: keep an approximate in-memory count of non-dead-lettered
    // rows so EnqueueAsync does not need to run a SELECT COUNT(*) on every call. The
    // counter is seeded from storage at construction, kept current by every mutation
    // (Enqueue, Drain, RetryDeadLettered, PurgeAgedDeadLetters, EnforceCapacity), and
    // periodically re-synced from storage as a safety net against drift.
    // Mutations cross threads (EnqueueAsync is called from the emitting thread, drain
    // runs on the timer / drain thread) so it is updated via Interlocked.
    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. an external process editing the DB).
    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="SqliteStoreAndForwardSink"/> class with the specified configuration.
    /// </summary>
    /// <param name="databasePath">The filesystem path to the SQLite database file.</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="clock">Optional clock function for testing; defaults to <see cref="DateTime.UtcNow"/>.</param>
    public SqliteStoreAndForwardSink(
        string databasePath,
        IAlarmHistorianWriter writer,
        ILogger logger,
        int batchSize = 100,
        long capacity = DefaultCapacity,
        TimeSpan? deadLetterRetention = null,
        Func<DateTime>? clock = null)
    {
        if (string.IsNullOrWhiteSpace(databasePath))
            throw new ArgumentException("Database path required.", nameof(databasePath));
        _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;
        _clock = clock ?? (() => DateTime.UtcNow);
        // DefaultTimeout gives ADO.NET command-level retry; the PRAGMA busy_timeout
        // applied in OpenConnection backs it with SQLite's own busy-handler so an
        // enqueue/drain collision waits out the file lock instead of throwing
        // SQLITE_BUSY immediately (Core.AlarmHistorian-004).
        _connectionString = new SqliteConnectionStringBuilder
        {
            DataSource = databasePath,
            DefaultTimeout = 5,
        }.ToString();

        InitializeSchema();
        // Core.AlarmHistorian-008: seed the in-memory counter from storage so the
        // perf-optimised EnqueueAsync path starts in sync with what's on disk.
        _queuedRowCount = ProbeQueuedRowCount();
    }

    /// <summary>
    ///     Open a connection with the busy timeout + WAL journal applied. SQLite
    ///     serializes writers with a file lock; the busy_timeout lets a writer wait
    ///     out a competing lock (default is 0 — fail fast), and WAL lets readers and
    ///     the single writer proceed without blocking each other.
    /// </summary>
    private SqliteConnection OpenConnection()
    {
        var conn = new SqliteConnection(_connectionString);
        conn.Open();
        ApplyPragmas(conn);
        return conn;
    }

    /// <summary>Apply busy_timeout + WAL pragmas to an already-open connection (sync).</summary>
    private static void ApplyPragmas(SqliteConnection conn)
    {
        using var pragma = conn.CreateCommand();
        pragma.CommandText = "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;";
        pragma.ExecuteNonQuery();
    }

    /// <summary>Apply busy_timeout + WAL pragmas to an already-open connection (async).</summary>
    private static async Task ApplyPragmasAsync(SqliteConnection conn, CancellationToken ct)
    {
        using var pragma = conn.CreateCommand();
        pragma.CommandText = "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;";
        await pragma.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
    }

    /// <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
    ///     (Core.AlarmHistorian-002). 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 (Core.AlarmHistorian-006).
    /// </remarks>
    /// <param name="tickInterval">The base interval between drain attempts.</param>
    public void StartDrainLoop(TimeSpan tickInterval)
    {
        if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
        _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 */ }
    }

    /// <summary>
    ///     Enqueues an alarm historian event asynchronously for forwarding to the historian.
    ///     Respects the queue capacity and enforces eviction of oldest rows when full.
    /// </summary>
    /// <remarks>
    ///     Core.AlarmHistorian-003: use async SQLite APIs so the emitting thread is not
    ///     blocked waiting for a file-lock or disk write; honor the cancellationToken
    ///     throughout. Microsoft.Data.Sqlite's async surface (OpenAsync /
    ///     ExecuteNonQueryAsync) is a thin wrapper over the synchronous path, so the
    ///     blocking still happens — but on a thread-pool thread, not the caller's thread.
    /// </remarks>
    /// <param name="evt">The alarm historian event to enqueue.</param>
    /// <param name="cancellationToken">Cancellation token for the operation.</param>
    public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
    {
        if (evt is null) throw new ArgumentNullException(nameof(evt));
        if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));

        using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
        await ApplyPragmasAsync(conn, cancellationToken).ConfigureAwait(false);

        // Core.AlarmHistorian-008: use the in-memory counter to short-circuit the
        // capacity check on every enqueue. The bare hot path is now one INSERT — no
        // SELECT COUNT(*). We fall back to a real probe only when the cached counter
        // says we're at or above capacity, or periodically to defend against drift.
        await EnforceCapacityFastPathAsync(conn, cancellationToken).ConfigureAwait(false);

        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount)
            VALUES ($alarmId, $enqueued, $payload, 0);
            """;
        cmd.Parameters.AddWithValue("$alarmId", evt.AlarmId);
        cmd.Parameters.AddWithValue("$enqueued", _clock().ToString("O"));
        cmd.Parameters.AddWithValue("$payload", JsonSerializer.Serialize(evt));
        await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);

        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(SqliteConnection conn, CancellationToken ct)
    {
        var enqueuesSinceResync = Interlocked.Increment(ref _enqueuesSinceResync);
        var cached = Interlocked.Read(ref _queuedRowCount);

        // Periodic resync — bounded amount of drift even under exotic conditions.
        if (enqueuesSinceResync >= ResyncEnqueueInterval)
        {
            await ResyncQueuedRowCountAsync(conn, 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(conn, 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 = OpenConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
        return (long)(cmd.ExecuteScalar() ?? 0L);
    }

    /// <summary>Re-sync the in-memory counter from storage (async path).</summary>
    private async Task ResyncQueuedRowCountAsync(SqliteConnection conn, CancellationToken ct)
    {
        Interlocked.Increment(ref _capacityProbeCount);
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
        var live = (long)(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L);
        Interlocked.Exchange(ref _queuedRowCount, live);
    }

    /// <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>
    ///     Core.AlarmHistorian-008: every per-tick SQLite operation runs through a
    ///     single shared connection (purge, read, corrupt-row dead-letter, and the
    ///     outcome-applying transaction). Pre-fix the drain opened three independent
    ///     connections per tick, each paying the open + PRAGMA cost.
    /// </remarks>
    /// <param name="ct">Cancellation token for the operation.</param>
    public async Task DrainOnceAsync(CancellationToken ct)
    {
        if (_disposed) return;
        if (!await _drainGate.WaitAsync(0, ct).ConfigureAwait(false)) return;
        try
        {
            lock (_statusLock)
            {
                _drainState = HistorianDrainState.Draining;
                _lastDrainUtc = _clock();
            }

            // One connection per drain tick — used by purge, read, corrupt-dead-letter,
            // and the outcome-applying transaction.
            using var conn = OpenConnection();

            PurgeAgedDeadLetters(conn);
            var batch = ReadBatch(conn);
            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 RowId so it cannot stall the queue head, and
            // exclude it from the batch handed to the writer.
            var corruptRowIds = batch.Where(r => r.Event is null).Select(r => r.RowId).ToList();
            var liveRows = batch.Where(r => r.Event is not null).ToList();
            var events = liveRows.Select(r => r.Event!).ToList();

            if (corruptRowIds.Count > 0)
            {
                using var corruptTx = conn.BeginTransaction();
                foreach (var rowId in corruptRowIds)
                    DeadLetterRow(conn, corruptTx, rowId, $"corrupt payload at {_clock():O}");
                corruptTx.Commit();
                // Each corrupt row leaves the non-dead-lettered queue — bookkeeping for
                // the in-memory counter (Core.AlarmHistorian-008).
                Interlocked.Add(ref _queuedRowCount, -corruptRowIds.Count);
                _logger.Warning(
                    "Dead-lettered {Count} historian queue row(s) with un-deserializable payload",
                    corruptRowIds.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)
            {
                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;
            }

            // Core.AlarmHistorian-007: a cardinality mismatch is a writer contract
            // violation — potentially the events were already persisted. Rather than
            // throwing (which, pre -006 fix, was swallowed and left _drainState
            // stale), treat it as a transient batch failure so the rows stay queued
            // and the backoff surface becomes visible to the operator. A deterministic
            // mismatch will stall the row until an operator intervenes or the writer
            // is fixed — far safer than re-throwing into a fire-and-forget timer.
            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;
            using (var tx = conn.BeginTransaction())
            {
                for (var i = 0; i < outcomes.Count; i++)
                {
                    var outcome = outcomes[i];
                    var rowId = liveRows[i].RowId;
                    switch (outcome)
                    {
                        case HistorianWriteOutcome.Ack:
                            DeleteRow(conn, tx, rowId);
                            rowsLeavingQueue++;
                            break;
                        case HistorianWriteOutcome.PermanentFail:
                            DeadLetterRow(conn, tx, rowId, $"permanent fail at {_clock():O}");
                            rowsLeavingQueue++;
                            break;
                        case HistorianWriteOutcome.RetryPlease:
                            BumpAttempt(conn, tx, rowId, "retry-please");
                            break;
                    }
                }
                tx.Commit();
            }
            // Ack-deleted + PermanentFail-dead-lettered rows both leave the
            // non-dead-lettered queue — keep the counter aligned (Core.AlarmHistorian-008).
            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
        {
            _drainGate.Release();
        }
    }

    /// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
    public HistorianSinkStatus GetStatus()
    {
        // Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
        // counter so a busy Admin UI / health probe does not hammer the DB. Dead-letter
        // depth is rare-path only (it lives in the queue until retention) so a real
        // COUNT(*) on a single combined connection is fine.
        var queued = Interlocked.Read(ref _queuedRowCount);
        if (queued < 0) queued = 0;

        long deadlettered;
        using (var conn = OpenConnection())
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 1";
            deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
        }

        // Core.AlarmHistorian-005: snapshot status fields atomically under the lock
        // so the Admin UI never sees a torn DateTime? or stale DrainState.
        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>
    public int RetryDeadLettered()
    {
        using var conn = OpenConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1";
        var revived = cmd.ExecuteNonQuery();
        // Dead-lettered rows rejoin the non-dead-lettered queue — keep the in-memory
        // counter aligned (Core.AlarmHistorian-008).
        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>PayloadJson</c> is corrupt or un-deserializable —
    ///     the <see cref="RowId"/> always stays bound to its own row so outcomes can
    ///     never be mapped to the wrong row.
    /// </summary>
    private readonly record struct QueueRow(long RowId, AlarmHistorianEvent? Event);

    private List<QueueRow> ReadBatch(SqliteConnection conn)
    {
        var rows = new List<QueueRow>();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            SELECT RowId, PayloadJson FROM Queue
            WHERE DeadLettered = 0
            ORDER BY RowId ASC
            LIMIT $limit
            """;
        cmd.Parameters.AddWithValue("$limit", _batchSize);
        using var reader = cmd.ExecuteReader();
        while (reader.Read())
        {
            var rowId = reader.GetInt64(0);
            var payload = reader.GetString(1);
            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(rowId, evt));
        }
        return rows;
    }

    private static void DeleteRow(SqliteConnection conn, SqliteTransaction tx, long rowId)
    {
        using var cmd = conn.CreateCommand();
        cmd.Transaction = tx;
        cmd.CommandText = "DELETE FROM Queue WHERE RowId = $id";
        cmd.Parameters.AddWithValue("$id", rowId);
        cmd.ExecuteNonQuery();
    }

    private void DeadLetterRow(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
    {
        using var cmd = conn.CreateCommand();
        cmd.Transaction = tx;
        cmd.CommandText = """
            UPDATE Queue SET DeadLettered = 1, LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
            WHERE RowId = $id
            """;
        cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
        cmd.Parameters.AddWithValue("$err", reason);
        cmd.Parameters.AddWithValue("$id", rowId);
        cmd.ExecuteNonQuery();
    }

    private void BumpAttempt(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
    {
        using var cmd = conn.CreateCommand();
        cmd.Transaction = tx;
        cmd.CommandText = """
            UPDATE Queue SET LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
            WHERE RowId = $id
            """;
        cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
        cmd.Parameters.AddWithValue("$err", reason);
        cmd.Parameters.AddWithValue("$id", rowId);
        cmd.ExecuteNonQuery();
    }

    // Async variant used by EnqueueAsync (Core.AlarmHistorian-003).
    // Core.AlarmHistorian-008: the precise path — runs COUNT(*) to compute the exact
    // number of rows to evict. Reached only from the fast-path fallback when the
    // in-memory counter says we are at or above capacity.
    private async Task EnforceCapacityAsync(SqliteConnection conn, CancellationToken ct)
    {
        Interlocked.Increment(ref _capacityProbeCount);
        long count;
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
            count = (long)(await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) ?? 0L);
        }
        // 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;
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = """
                DELETE FROM Queue
                WHERE RowId IN (
                    SELECT RowId FROM Queue
                    WHERE DeadLettered = 0
                    ORDER BY RowId ASC
                    LIMIT $n
                )
                """;
            cmd.Parameters.AddWithValue("$n", toEvict);
            await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
        }
        Interlocked.Add(ref _queuedRowCount, -toEvict);
        lock (_statusLock) { _evictedCount += toEvict; }
        _logger.Warning(
            "Historian queue at capacity {Cap} — evicted {Count} oldest row(s) to make room (lifetime evictions: {Total})",
            _capacity, toEvict, _evictedCount);
    }

    private void PurgeAgedDeadLetters(SqliteConnection conn)
    {
        var cutoff = (_clock() - _deadLetterRetention).ToString("O");
        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            DELETE FROM Queue
            WHERE DeadLettered = 1 AND LastAttemptUtc IS NOT NULL AND LastAttemptUtc < $cutoff
            """;
        cmd.Parameters.AddWithValue("$cutoff", cutoff);
        var purged = cmd.ExecuteNonQuery();
        if (purged > 0)
            _logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged);
    }

    private void InitializeSchema()
    {
        using var conn = OpenConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            CREATE TABLE IF NOT EXISTS Queue (
                RowId INTEGER PRIMARY KEY AUTOINCREMENT,
                AlarmId TEXT NOT NULL,
                EnqueuedUtc TEXT NOT NULL,
                PayloadJson TEXT NOT NULL,
                AttemptCount INTEGER NOT NULL DEFAULT 0,
                LastAttemptUtc TEXT NULL,
                LastError TEXT NULL,
                DeadLettered INTEGER NOT NULL DEFAULT 0
            );
            CREATE INDEX IF NOT EXISTS IX_Queue_Drain ON Queue (DeadLettered, RowId);
            """;
        cmd.ExecuteNonQuery();
    }

    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.</summary>
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        _drainTimer?.Dispose();
        _drainGate.Dispose();
    }
}
