using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Verifies the durable SQLite store-and-forward queue behind the historian sink:
///     round-trip Ack, backoff ladder on RetryPlease, dead-lettering on PermanentFail,
///     capacity eviction, and retention-based dead-letter purge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class LocalDbStoreAndForwardSinkTests : IDisposable
{
    private readonly string _dbPath;
    private readonly ILogger _log;
    private ServiceProvider? _provider;
    private ILocalDb? _db;

    /// <summary>Initializes a new test instance.</summary>
    public LocalDbStoreAndForwardSinkTests()
    {
        _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite");
        _log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger();
    }

    /// <summary>
    ///     A real temp-file local database with the buffer table created and registered, built on
    ///     first use.
    /// </summary>
    /// <remarks>
    ///     A real database rather than a stub. The library has no in-memory mode, and a bare
    ///     <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture
    ///     triggers call — so every write to a registered table would fail closed. Registering here
    ///     also means these tests exercise the same capture path production replicates through.
    /// </remarks>
    private ILocalDb Db
    {
        get
        {
            if (_db is not null) return _db;

            var configuration = new ConfigurationBuilder()
                .AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
                .Build();

            _provider = new ServiceCollection()
                .AddZbLocalDb(configuration, db =>
                {
                    using var connection = db.CreateConnection();
                    AlarmSfSchema.Apply(connection);
                    db.RegisterReplicated(AlarmSfSchema.EventsTable);
                })
                .BuildServiceProvider();

            return _db = _provider.GetRequiredService<ILocalDb>();
        }
    }

    /// <summary>Cleans up test resources.</summary>
    public void Dispose()
    {
        _provider?.Dispose();

        // Pooled connections keep a handle open past dispose; clearing the pools first is what
        // makes the delete actually succeed.
        SqliteConnection.ClearAllPools();

        foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
        {
            try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
        }
    }

    private sealed class FakeWriter : IAlarmHistorianWriter
    {
        /// <summary>Gets the queue of outcomes per event.</summary>
        public Queue<HistorianWriteOutcome> NextOutcomePerEvent { get; } = new();
        /// <summary>Gets or sets the default outcome for events.</summary>
        public HistorianWriteOutcome DefaultOutcome { get; set; } = HistorianWriteOutcome.Ack;
        /// <summary>Gets the batches that have been written.</summary>
        public List<IReadOnlyList<AlarmHistorianEvent>> Batches { get; } = [];
        /// <summary>Gets or sets an exception to throw once.</summary>
        public Exception? ThrowOnce { get; set; }

        /// <summary>Writes a batch of events.</summary>
        /// <param name="batch">Events to write.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns>A task returning the write outcomes.</returns>
        public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
            IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
        {
            if (ThrowOnce is not null)
            {
                var e = ThrowOnce;
                ThrowOnce = null;
                throw e;
            }
            Batches.Add(batch);
            var outcomes = new List<HistorianWriteOutcome>();
            for (var i = 0; i < batch.Count; i++)
                outcomes.Add(NextOutcomePerEvent.Count > 0 ? NextOutcomePerEvent.Dequeue() : DefaultOutcome);
            return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
        }
    }

    private static AlarmHistorianEvent Event(string alarmId, DateTime? ts = null) => new(
        AlarmId: alarmId,
        EquipmentPath: "/Site/Line1/Cell",
        AlarmName: "HighTemp",
        AlarmTypeName: "LimitAlarm",
        Severity: AlarmSeverity.High,
        EventKind: "Activated",
        Message: "temp exceeded",
        User: "system",
        Comment: null,
        TimestampUtc: ts ?? DateTime.UtcNow);

    /// <summary>Verifies that acknowledged events are removed from the queue.</summary>
    [Fact]
    public async Task EnqueueThenDrain_Ack_removes_row()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(1);

        await sink.DrainOnceAsync(CancellationToken.None);

        writer.Batches.Count.ShouldBe(1);
        writer.Batches[0].Count.ShouldBe(1);
        writer.Batches[0][0].AlarmId.ShouldBe("A1");
        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(0);
        status.DeadLetterDepth.ShouldBe(0);
        status.LastSuccessUtc.ShouldNotBeNull();
    }

    /// <summary>Verifies that draining an empty queue is a no-op.</summary>
    [Fact]
    public async Task Drain_with_empty_queue_is_noop()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.DrainOnceAsync(CancellationToken.None);

        writer.Batches.ShouldBeEmpty();
        sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.Idle);
    }

    /// <summary>Verifies that RetryPlease outcome bumps backoff and keeps the row queued.</summary>
    [Fact]
    public async Task RetryPlease_bumps_backoff_and_keeps_row()
    {
        var writer = new FakeWriter();
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        var before = sink.CurrentBackoff;
        await sink.DrainOnceAsync(CancellationToken.None);

        sink.CurrentBackoff.ShouldBeGreaterThan(before);
        sink.GetStatus().QueueDepth.ShouldBe(1, "row stays in queue for retry");
        sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.BackingOff);
    }

    /// <summary>Verifies that an Ack after RetryPlease resets backoff to the floor.</summary>
    [Fact]
    public async Task Ack_after_Retry_resets_backoff()
    {
        var writer = new FakeWriter();
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);
        sink.CurrentBackoff.ShouldBeGreaterThan(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(1));

        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
        await sink.DrainOnceAsync(CancellationToken.None);

        sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(1));
        sink.GetStatus().QueueDepth.ShouldBe(0);
    }

    /// <summary>Verifies that PermanentFail outcome dead-letters only the failed event.</summary>
    [Fact]
    public async Task PermanentFail_dead_letters_one_row_only()
    {
        var writer = new FakeWriter();
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
        await sink.EnqueueAsync(Event("good"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(0, "good row acked");
        status.DeadLetterDepth.ShouldBe(1, "bad row dead-lettered");
    }

    /// <summary>Verifies that writer exceptions trigger retry for the entire batch.</summary>
    [Fact]
    public async Task Writer_exception_treated_as_retry_for_whole_batch()
    {
        var writer = new FakeWriter { ThrowOnce = new InvalidOperationException("pipe broken") };
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(1);
        status.LastError.ShouldBe("pipe broken");
        status.DrainState.ShouldBe(HistorianDrainState.BackingOff);

        // Next drain after the writer recovers should Ack.
        await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(0);
    }

    /// <summary>Verifies that capacity eviction drops the oldest non-dead-lettered row.</summary>
    [Fact]
    public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, batchSize: 100, capacity: 3);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
        await sink.EnqueueAsync(Event("A3"), CancellationToken.None);
        // A4 enqueue must evict the oldest (A1).
        await sink.EnqueueAsync(Event("A4"), CancellationToken.None);

        sink.GetStatus().QueueDepth.ShouldBe(3);

        await sink.DrainOnceAsync(CancellationToken.None);
        var drained = writer.Batches[0].Select(e => e.AlarmId).ToArray();
        drained.ShouldNotContain("A1");
        drained.ShouldContain("A2");
        drained.ShouldContain("A3");
        drained.ShouldContain("A4");
    }

    /// <summary>Verifies that dead-lettered rows are purged after retention period expires.</summary>
    [Fact]
    public async Task Deadlettered_rows_are_purged_past_retention()
    {
        var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        DateTime clock = now;

        var writer = new FakeWriter();
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, deadLetterRetention: TimeSpan.FromDays(30),
            clock: () => clock);

        await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().DeadLetterDepth.ShouldBe(1);

        // Advance past retention + tick drain (which runs PurgeAgedDeadLetters).
        clock = now.AddDays(31);
        await sink.DrainOnceAsync(CancellationToken.None);

        sink.GetStatus().DeadLetterDepth.ShouldBe(0, "purged past retention");
    }

    /// <summary>Verifies that RetryDeadLettered requeues dead-lettered rows for retry.</summary>
    [Fact]
    public async Task RetryDeadLettered_requeues_for_retry()
    {
        var writer = new FakeWriter();
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().DeadLetterDepth.ShouldBe(1);

        var revived = sink.RetryDeadLettered();
        revived.ShouldBe(1);

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(1);
        status.DeadLetterDepth.ShouldBe(0);
    }

    /// <summary>Verifies that the exponential backoff ladder caps at 60 seconds.</summary>
    [Fact]
    public async Task Backoff_ladder_caps_at_60s()
    {
        var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);

        // 10 retry rounds — ladder should cap at 60s.
        for (var i = 0; i < 10; i++)
            await sink.DrainOnceAsync(CancellationToken.None);

        sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(60));
    }

    /// <summary>
    ///     Regression for finding 002: a permanently-malformed (poison) event that the
    ///     writer can only ever map to <see cref="HistorianWriteOutcome.RetryPlease"/>
    ///     must NOT retry forever. After <c>maxAttempts</c> retry-please drains the row
    ///     is dead-lettered so the queue head can never stall on it indefinitely.
    /// </summary>
    [Fact]
    public async Task RetryPlease_dead_letters_row_after_MaxAttempts()
    {
        var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, maxAttempts: 3);

        await sink.EnqueueAsync(Event("poison"), CancellationToken.None);

        // Drain 1 + 2: still retrying — the row stays queued, not yet dead-lettered.
        await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(1, "after 1 attempt the row is still queued");
        sink.GetStatus().DeadLetterDepth.ShouldBe(0, "not yet dead-lettered after 1 attempt");

        await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(1, "after 2 attempts the row is still queued");
        sink.GetStatus().DeadLetterDepth.ShouldBe(0, "not yet dead-lettered after 2 attempts");

        // Drain 3: the 3rd attempt hits the cap — dead-letter it.
        await sink.DrainOnceAsync(CancellationToken.None);
        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(0, "row left the live queue once max attempts exceeded");
        status.DeadLetterDepth.ShouldBe(1, "poison row dead-lettered at the max-attempts cap");
    }

    /// <summary>Verifies that NullAlarmHistorianSink reports disabled status.</summary>
    [Fact]
    public void NullAlarmHistorianSink_reports_disabled_status()
    {
        var s = NullAlarmHistorianSink.Instance.GetStatus();
        s.DrainState.ShouldBe(HistorianDrainState.Disabled);
        s.QueueDepth.ShouldBe(0);
    }

    /// <summary>Verifies that NullAlarmHistorianSink silently swallows enqueue calls.</summary>
    [Fact]
    public async Task NullAlarmHistorianSink_swallows_enqueue()
    {
        // Should not throw or persist anything.
        await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None);
    }

    /// <summary>
    ///     A closed drain gate must leave the queue completely alone — no writer call, no attempt
    ///     bump, no purge.
    /// </summary>
    /// <remarks>
    ///     This is the property that keeps alarm delivery exactly-once across a redundant pair.
    ///     The buffer replicates, so the Secondary holds a full copy of the Primary's queue; a
    ///     Secondary that drained would re-send every event, continuously.
    /// </remarks>
    [Fact]
    public async Task Drain_does_nothing_while_the_gate_is_closed()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, drainGate: () => false);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.EnqueueAsync(Event("A2"), CancellationToken.None);

        await sink.DrainOnceAsync(CancellationToken.None);

        writer.Batches.Count.ShouldBe(0, "a gated-off node must not deliver");

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(2, "the rows stay queued for whichever node holds the Primary role");
        status.DrainState.ShouldBe(
            HistorianDrainState.NotPrimary,
            "a Secondary's rising queue must be distinguishable from a stalled drain");
    }

    /// <summary>
    ///     The positive control for the case above: the same queue, the same sink, drains as soon
    ///     as the gate opens. Without this, a sink that was simply broken would pass the
    ///     gated-off assertion.
    /// </summary>
    [Fact]
    public async Task Drain_delivers_the_retained_queue_once_the_gate_opens()
    {
        var writer = new FakeWriter();
        var isPrimary = false;
        // ReSharper disable once AccessToModifiedClosure — flipping it mid-test is the point.
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, drainGate: () => isPrimary);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);
        writer.Batches.Count.ShouldBe(0);

        isPrimary = true;   // this node is promoted
        await sink.DrainOnceAsync(CancellationToken.None);

        writer.Batches.Count.ShouldBe(1);
        writer.Batches[0].Select(e => e.AlarmId).ShouldBe(["A1"]);
        sink.GetStatus().QueueDepth.ShouldBe(0);
    }

    /// <summary>
    ///     A gate that throws must be read as "not now", never as permission. Draining on a gate
    ///     fault would put a pair into dual-delivery precisely when its redundancy signal is sick.
    /// </summary>
    [Fact]
    public async Task Drain_treats_a_throwing_gate_as_closed()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, drainGate: () => throw new InvalidOperationException("role unavailable"));

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);

        writer.Batches.Count.ShouldBe(0, "a faulted gate must fail closed");
        sink.GetStatus().QueueDepth.ShouldBe(1);
    }

    /// <summary>Verifies that the constructor rejects invalid arguments.</summary>
    [Fact]
    public void Ctor_rejects_bad_args()
    {
        var w = new FakeWriter();
        Should.Throw<ArgumentNullException>(() => new LocalDbStoreAndForwardSink(null!, w, _log));
        Should.Throw<ArgumentNullException>(() => new LocalDbStoreAndForwardSink(Db, null!, _log));
        Should.Throw<ArgumentNullException>(() => new LocalDbStoreAndForwardSink(Db, w, null!));
        Should.Throw<ArgumentOutOfRangeException>(() => new LocalDbStoreAndForwardSink(Db, w, _log, batchSize: 0));
        Should.Throw<ArgumentOutOfRangeException>(() => new LocalDbStoreAndForwardSink(Db, w, _log, capacity: 0));
        Should.Throw<ArgumentOutOfRangeException>(() => new LocalDbStoreAndForwardSink(Db, w, _log, maxAttempts: 0));
    }

    /// <summary>Verifies that a disposed sink rejects enqueue operations.</summary>
    [Fact]
    public async Task Disposed_sink_rejects_enqueue()
    {
        var writer = new FakeWriter();
        var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
        sink.Dispose();

        await Should.ThrowAsync<ObjectDisposedException>(
            () => sink.EnqueueAsync(Event("A1"), CancellationToken.None));
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-001: a corrupt / un-deserializable
    ///     PayloadJson row sitting between good rows must not desync the rowId/event
    ///     lists. The corrupt row is dead-lettered (so it cannot stall the queue head
    ///     forever), and every good row's outcome is applied to the CORRECT RowId —
    ///     no good event is silently lost.
    /// </summary>
    [Fact]
    public async Task Drain_with_corrupt_payload_row_deadletters_it_and_keeps_good_rows_aligned()
    {
        var writer = new FakeWriter();
        var clock = new TickingClock();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, clock: clock.Next);

        // Row 1: good. Row 2: corrupt JSON (inserted directly). Row 3: good. The shared ticking
        // clock is what pins the corrupt row BETWEEN the good ones — which is the whole point of
        // this case, as distinct from the corrupt-at-the-head case below.
        await sink.EnqueueAsync(Event("good-1"), CancellationToken.None);
        InsertCorruptRow("corrupt", clock.Next());
        await sink.EnqueueAsync(Event("good-2"), CancellationToken.None);

        await sink.DrainOnceAsync(CancellationToken.None);

        // The writer must only see the two well-formed events, in order.
        writer.Batches.Count.ShouldBe(1);
        writer.Batches[0].Select(e => e.AlarmId).ShouldBe(["good-1", "good-2"]);

        var status = sink.GetStatus();
        // Both good rows acked + removed; the corrupt row is dead-lettered, not lost.
        status.QueueDepth.ShouldBe(0, "both good rows acked and removed");
        status.DeadLetterDepth.ShouldBe(1, "corrupt row dead-lettered, not silently dropped");

        // A second drain must be a clean no-op — the corrupt row no longer stalls the head.
        await sink.DrainOnceAsync(CancellationToken.None);
        writer.Batches.Count.ShouldBe(1, "no further batches — corrupt row no longer re-read");
    }

    /// <summary>
    ///     A corrupt row at the very head of the queue must be dead-lettered and not
    ///     prevent the good rows behind it from draining.
    /// </summary>
    [Fact]
    public async Task Drain_with_corrupt_head_row_does_not_stall_queue()
    {
        var writer = new FakeWriter();
        var clock = new TickingClock();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log, clock: clock.Next);

        InsertCorruptRow("corrupt-head", clock.Next());
        await sink.EnqueueAsync(Event("good-1"), CancellationToken.None);

        await sink.DrainOnceAsync(CancellationToken.None);

        writer.Batches.Count.ShouldBe(1);
        writer.Batches[0].Select(e => e.AlarmId).ShouldBe(["good-1"]);

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(0);
        status.DeadLetterDepth.ShouldBe(1);
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-002: the drain loop must honor the
    ///     exponential backoff. A writer that always returns RetryPlease pushes the
    ///     sink into BackingOff; with a tiny tick interval the timer would otherwise
    ///     hammer the writer. We assert that after the backoff ladder advances, the
    ///     observed inter-batch gap actually grows beyond the bare tick interval.
    /// </summary>
    [Fact]
    public async Task StartDrainLoop_honors_backoff_and_slows_cadence_under_retry()
    {
        var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);

        // Tiny tick — without backoff this would produce dozens of batches/second.
        sink.StartDrainLoop(TimeSpan.FromMilliseconds(20));

        // Give the loop ~1.5s. Backoff ladder is 1s,2s,... so after the first
        // retry tick the next tick is deferred by ~1s. We should therefore see
        // only a small number of batches, NOT a fixed-20ms hammer (~75 batches).
        await Task.Delay(TimeSpan.FromMilliseconds(1500), TestContext.Current.CancellationToken);

        writer.Batches.Count.ShouldBeLessThan(10,
            "backoff must throttle the drain cadence — a fixed-tick hammer would be far higher");
        writer.Batches.Count.ShouldBeGreaterThan(0, "the loop must still run at least once");
        sink.CurrentBackoff.ShouldBeGreaterThan(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(1));
    }

    /// <summary>
    ///     The drain loop on a fixed tick with a healthy writer keeps draining at the
    ///     steady cadence (backoff stays at the floor) — confirms the reschedule path
    ///     does not get stuck after a successful tick.
    /// </summary>
    [Fact]
    public async Task StartDrainLoop_keeps_steady_cadence_when_writer_is_healthy()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        sink.StartDrainLoop(TimeSpan.FromMilliseconds(30));

        // Enqueue a few events over time; each should be drained promptly.
        for (var i = 0; i < 4; i++)
        {
            await sink.EnqueueAsync(Event($"A{i}"), CancellationToken.None);
            await Task.Delay(TimeSpan.FromMilliseconds(120), TestContext.Current.CancellationToken);
        }

        sink.GetStatus().QueueDepth.ShouldBe(0, "healthy writer drains every event at the steady tick");
        sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(1), "backoff stays at the floor when no retry occurs");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-006: a fault thrown out of the drain
    ///     work must not be lost as an unobserved async-void task exception. It must
    ///     be recorded into the status surface (LastError) and the drain loop must
    ///     keep rescheduling rather than silently dying.
    /// </summary>
    [Fact]
    public async Task StartDrainLoop_records_drain_fault_and_keeps_running()
    {
        // A writer that throws a non-Exception-message-y fault on the first call,
        // then recovers. The throw escapes WriteBatchAsync's own try as a writer
        // exception... so to exercise the *callback* catch we instead make the
        // fault originate from the writer itself but assert the loop self-heals.
        var writer = new ThrowingThenHealingWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        sink.StartDrainLoop(TimeSpan.FromMilliseconds(30));

        // First tick faults; the loop must reschedule and the later tick must succeed.
        var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
        while (sink.GetStatus().QueueDepth > 0 && DateTime.UtcNow < deadline)
            await Task.Delay(50, TestContext.Current.CancellationToken);

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(0, "the loop recovered and drained the row after the fault");
        writer.CallCount.ShouldBeGreaterThan(1, "the drain loop kept running past the faulting tick");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-004: concurrent EnqueueAsync (emitting
    ///     thread) and DrainOnceAsync (drain thread) must not throw SQLITE_BUSY. With
    ///     the busy_timeout + WAL pragmas in place the loser of the file-lock race
    ///     waits the lock out instead of failing fast.
    /// </summary>
    [Fact]
    public async Task Concurrent_enqueue_and_drain_do_not_throw_sqlite_busy()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        var faults = new List<Exception>();
        var enqueuers = Enumerable.Range(0, 4).Select(t => Task.Run(async () =>
        {
            try
            {
                for (var i = 0; i < 50; i++)
                    await sink.EnqueueAsync(Event($"T{t}-{i}"), CancellationToken.None);
            }
            catch (Exception ex) { lock (faults) faults.Add(ex); }
        }));

        var drainers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
        {
            try
            {
                for (var i = 0; i < 50; i++)
                {
                    await sink.DrainOnceAsync(CancellationToken.None);
                    await Task.Delay(1);
                }
            }
            catch (Exception ex) { lock (faults) faults.Add(ex); }
        }));

        await Task.WhenAll(enqueuers.Concat(drainers));

        faults.ShouldBeEmpty(
            "busy_timeout + WAL must absorb enqueue/drain lock contention without SQLITE_BUSY");

        // Drain whatever is left and confirm everything made it through.
        for (var i = 0; i < 5; i++)
            await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(0);
    }

    /// <summary>A writer that throws once, then behaves normally — used to prove the drain loop self-heals.</summary>
    private sealed class ThrowingThenHealingWriter : IAlarmHistorianWriter
    {
        /// <summary>Gets the number of times WriteBatchAsync has been called.</summary>
        public int CallCount { get; private set; }

        /// <summary>Writes a batch of events, throwing once then recovering.</summary>
        /// <param name="batch">Events to write.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns>A task returning the write outcomes.</returns>
        public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
            IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
        {
            CallCount++;
            if (CallCount == 1)
                throw new InvalidOperationException("transient historian fault");
            var outcomes = Enumerable.Repeat(HistorianWriteOutcome.Ack, batch.Count).ToList();
            return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
        }
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-007: when <see cref="IAlarmHistorianWriter"/>
    ///     returns a wrong-cardinality outcome list the drain must NOT throw — it must
    ///     log the violation, set <c>DrainState = BackingOff</c>, bump the backoff, and
    ///     leave every row in the queue for the next drain attempt. Pre-fix this path
    ///     threw <see cref="InvalidOperationException"/> which (post -006 fix) was caught
    ///     by the timer callback, but still left the rows stranded on the first
    ///     cardinality-mismatched tick.
    /// </summary>
    [Fact]
    public async Task Writer_returning_wrong_cardinality_outcomes_sets_backing_off_and_keeps_rows()
    {
        var writer = new WrongCardinalityWriter(returnExtraOutcome: true);
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.EnqueueAsync(Event("A2"), CancellationToken.None);

        // Writer will return 3 outcomes for 2 events — cardinality violation.
        await sink.DrainOnceAsync(CancellationToken.None);

        var status = sink.GetStatus();
        status.QueueDepth.ShouldBe(2, "rows must stay queued — no outcome was applied");
        status.DrainState.ShouldBe(HistorianDrainState.BackingOff, "mismatch is a transient error");
        status.LastError.ShouldNotBeNullOrEmpty("violation message must surface on the status");
        sink.CurrentBackoff.ShouldBeGreaterThan(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(1),
            "backoff should have been bumped");

        // After the writer is fixed the rows drain cleanly.
        writer.FixWriter();
        await sink.DrainOnceAsync(CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(0, "once the writer is fixed the rows drain");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-009: when the queue reaches capacity the
    ///     evicted row count must be surfaced in <see cref="HistorianSinkStatus.EvictedCount"/>
    ///     so operators can detect bounded-durability overflow without log scraping.
    /// </summary>
    [Fact]
    public async Task Capacity_eviction_increments_evicted_count_on_status()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, batchSize: 100, capacity: 3);

        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
        await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
        await sink.EnqueueAsync(Event("A3"), CancellationToken.None);

        // A4 triggers eviction of one row.
        await sink.EnqueueAsync(Event("A4"), CancellationToken.None);
        sink.GetStatus().EvictedCount.ShouldBe(1, "one eviction reported on status");

        // A5 triggers another eviction.
        await sink.EnqueueAsync(Event("A5"), CancellationToken.None);
        sink.GetStatus().EvictedCount.ShouldBe(2, "eviction counter accumulates");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-005: GetStatus must return a consistent
    ///     snapshot of all status fields — no torn DateTime? or stale DrainState.
    ///     Drive status writes from one thread and reads from another concurrently.
    /// </summary>
    [Fact]
    public async Task GetStatus_snapshot_is_consistent_under_concurrent_drain()
    {
        var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        // Fill the queue.
        for (var i = 0; i < 10; i++)
            await sink.EnqueueAsync(Event($"A{i}"), CancellationToken.None);

        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

        var drainer = Task.Run(async () =>
        {
            for (var i = 0; i < 30 && !cts.Token.IsCancellationRequested; i++)
            {
                await sink.DrainOnceAsync(CancellationToken.None);
                await Task.Delay(5, CancellationToken.None);
            }
        }, cts.Token);

        var reader = Task.Run(() =>
        {
            for (var i = 0; i < 50; i++)
            {
                // Should not throw — no torn reads.
                var s = sink.GetStatus();
                _ = s.DrainState;
                _ = s.LastDrainUtc;
                _ = s.LastSuccessUtc;
                _ = s.LastError;
            }
        }, cts.Token);

        // Neither task should throw.
        await Task.WhenAll(drainer, reader);
    }

    /// <summary>
    ///     A writer that returns more outcomes than events on the first call, then
    ///     behaves normally after <see cref="FixWriter"/> is called.
    /// </summary>
    private sealed class WrongCardinalityWriter : IAlarmHistorianWriter
    {
        private bool _returnExtra;

        /// <summary>Initializes a new instance.</summary>
        /// <param name="returnExtraOutcome">Whether to return an extra outcome on the first call.</param>
        public WrongCardinalityWriter(bool returnExtraOutcome) => _returnExtra = returnExtraOutcome;
        /// <summary>Fixes the writer to return correct cardinality.</summary>
        public void FixWriter() => _returnExtra = false;

        /// <summary>Writes a batch of events, returning wrong cardinality until fixed.</summary>
        /// <param name="batch">Events to write.</param>
        /// <param name="ct">Cancellation token.</param>
        /// <returns>A task returning the write outcomes.</returns>
        public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
            IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
        {
            var outcomes = Enumerable.Repeat(HistorianWriteOutcome.Ack,
                _returnExtra ? batch.Count + 1 : batch.Count).ToList();
            return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
        }
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-008: <c>EnqueueAsync</c> must NOT run a
    ///     <c>SELECT COUNT(*)</c> on every enqueue when we are far below capacity. The
    ///     optimisation tracks the queue depth in memory and only consults the database
    ///     when the cached value indicates the capacity wall is in reach. This regression
    ///     pins the perf characteristic: after many enqueues below capacity, the
    ///     capacity-probe count must stay bounded — not grow proportionally to the
    ///     enqueue count as the un-optimised path did.
    /// </summary>
    [Fact]
    public async Task EnqueueAsync_does_not_count_all_rows_on_every_call_below_capacity()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, batchSize: 100, capacity: 10_000);

        for (var i = 0; i < 200; i++)
            await sink.EnqueueAsync(Event($"E{i}"), CancellationToken.None);

        // Pre-fix: probe count == enqueue count (200). Post-fix: ≤ a handful (initial
        // load + occasional periodic re-syncs). 25 is generous headroom.
        sink.DebugCapacityProbeCount.ShouldBeLessThan(25,
            "EnqueueAsync must not run a per-call SELECT COUNT(*) below capacity");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-008: across every queue mutation (enqueue,
    ///     Ack drain, PermanentFail drain, capacity eviction, RetryDeadLettered) the
    ///     queue depth reported by <see cref="LocalDbStoreAndForwardSink.GetStatus"/> must
    ///     stay aligned with a fresh <c>COUNT(*)</c> against the database. Catches drift
    ///     bugs in the in-memory counter introduced by the perf optimisation.
    /// </summary>
    [Fact]
    public async Task Enqueue_and_drain_keep_queue_depth_consistent_with_storage()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(
            Db, writer, _log, batchSize: 5, capacity: 8);

        // Burst-enqueue below capacity — the in-memory counter must stay aligned with the
        // SELECT COUNT(*) that GetStatus runs.
        for (var i = 0; i < 6; i++)
            await sink.EnqueueAsync(Event($"burst-{i}"), CancellationToken.None);
        AssertQueueDepthMatchesStorage(sink);
        sink.GetStatus().QueueDepth.ShouldBe(6);

        // Push past capacity — capacity must still be enforced even when EnqueueAsync no
        // longer runs COUNT(*) on every call.
        for (var i = 0; i < 5; i++)
            await sink.EnqueueAsync(Event($"overflow-{i}"), CancellationToken.None);
        sink.GetStatus().QueueDepth.ShouldBe(8, "capacity must still be honoured by the perf-optimised path");
        sink.GetStatus().EvictedCount.ShouldBe(3, "eviction counter must reflect every evicted row");
        AssertQueueDepthMatchesStorage(sink);

        // Drain a partial batch (Ack) — the in-memory counter must follow the deletes
        // applied inside the single consolidated drain transaction.
        await sink.DrainOnceAsync(CancellationToken.None);
        AssertQueueDepthMatchesStorage(sink);

        // Add a dead-lettered row and verify the counter does NOT include it (QueueDepth
        // is non-dead-lettered only).
        writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
        await sink.EnqueueAsync(Event("to-dead-letter"), CancellationToken.None);
        await sink.DrainOnceAsync(CancellationToken.None);
        AssertQueueDepthMatchesStorage(sink);
        sink.GetStatus().DeadLetterDepth.ShouldBeGreaterThanOrEqualTo(1);

        // RetryDeadLettered moves DLQ rows back into the live queue — the counter must
        // pick that up.
        sink.RetryDeadLettered();
        AssertQueueDepthMatchesStorage(sink);
    }

    /// <summary>
    ///     Stress regression for Core.AlarmHistorian-008: interleave many enqueues and
    ///     drains across threads and confirm the in-memory counter stays consistent
    ///     with storage. Catches drift bugs in the optimised path that would only show
    ///     up under contention.
    /// </summary>
    [Fact]
    public async Task Counter_remains_consistent_under_concurrent_enqueue_and_drain()
    {
        var writer = new FakeWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);

        var enqueuers = Enumerable.Range(0, 3).Select(t => Task.Run(async () =>
        {
            for (var i = 0; i < 60; i++)
                await sink.EnqueueAsync(Event($"T{t}-{i}"), CancellationToken.None);
        }));
        var drainers = Enumerable.Range(0, 2).Select(_ => Task.Run(async () =>
        {
            for (var i = 0; i < 30; i++)
            {
                await sink.DrainOnceAsync(CancellationToken.None);
                await Task.Delay(2);
            }
        }));

        await Task.WhenAll(enqueuers.Concat(drainers));

        // Drain anything left over.
        for (var i = 0; i < 10; i++)
            await sink.DrainOnceAsync(CancellationToken.None);

        AssertQueueDepthMatchesStorage(sink);
        sink.GetStatus().QueueDepth.ShouldBe(0, "every event drained at the end of the run");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-012: when WriteBatchAsync throws
    ///     <see cref="OperationCanceledException"/> the drain exits via re-throw
    ///     without resetting <c>_drainState</c>, leaving the status surface permanently
    ///     showing <c>Draining</c>. The next call to <see cref="LocalDbStoreAndForwardSink.GetStatus"/>
    ///     must reflect <c>Idle</c> or <c>BackingOff</c>, not the stale <c>Draining</c>
    ///     state that was set at the top of the drain tick.
    /// </summary>
    [Fact]
    public async Task Drain_cancelled_mid_write_resets_drain_state_to_not_draining()
    {
        var writer = new CancellableWriter();
        using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
        await sink.EnqueueAsync(Event("A1"), CancellationToken.None);

        using var cts = new CancellationTokenSource();
        writer.CancelOn(cts);

        // The drain will cancel inside WriteBatchAsync — OperationCanceledException re-throws.
        await Should.ThrowAsync<OperationCanceledException>(
            () => sink.DrainOnceAsync(cts.Token));

        // DrainState must NOT be left as Draining after the cancellation escapes.
        sink.GetStatus().DrainState.ShouldNotBe(HistorianDrainState.Draining,
            "cancellation must clear the Draining status; row stays queued for next tick");

        // The row must still be in the queue — the cancelled drain applied no outcomes.
        sink.GetStatus().QueueDepth.ShouldBe(1, "row stays queued when drain is cancelled");
    }

    /// <summary>A writer that cancels its own CancellationTokenSource on the first call.</summary>
    private sealed class CancellableWriter : IAlarmHistorianWriter
    {
        private CancellationTokenSource? _cts;

        /// <summary>Register the token source to cancel on first write.</summary>
        public void CancelOn(CancellationTokenSource cts) => _cts = cts;

        /// <summary>Writes a batch; cancels via the registered CTS on the first call.</summary>
        public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
            IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
        {
            _cts?.Cancel();
            ct.ThrowIfCancellationRequested();
            var outcomes = Enumerable.Repeat(HistorianWriteOutcome.Ack, batch.Count).ToList();
            return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
        }
    }

    /// <summary>
    ///     Helper that confirms the queue depth surfaced by GetStatus matches a fresh
    ///     COUNT(*) read directly from storage — proves the in-memory counter has not
    ///     drifted from the persisted truth.
    /// </summary>
    private void AssertQueueDepthMatchesStorage(LocalDbStoreAndForwardSink sink)
    {
        using var conn = Db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT COUNT(*) FROM alarm_sf_events WHERE dead_lettered = 0";
        var live = (long)(cmd.ExecuteScalar() ?? 0L);
        sink.GetStatus().QueueDepth.ShouldBe(live, "GetStatus must agree with a fresh COUNT(*)");
    }

    /// <summary>
    ///     Regression for Core.AlarmHistorian-014: <see cref="LocalDbStoreAndForwardSink.GetStatus"/>
    ///     and <see cref="LocalDbStoreAndForwardSink.RetryDeadLettered"/> must throw
    ///     <see cref="ObjectDisposedException"/> after <see cref="LocalDbStoreAndForwardSink.Dispose"/>
    ///     is called, consistent with the guards already present on
    ///     <see cref="LocalDbStoreAndForwardSink.EnqueueAsync"/> and
    ///     <see cref="LocalDbStoreAndForwardSink.StartDrainLoop"/>.
    /// </summary>
    [Fact]
    public void Disposed_sink_throws_ObjectDisposedException_on_GetStatus_and_RetryDeadLettered()
    {
        var writer = new FakeWriter();
        var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
        sink.Dispose();

        Should.Throw<ObjectDisposedException>(() => sink.GetStatus());
        Should.Throw<ObjectDisposedException>(() => sink.RetryDeadLettered());
    }

    /// <summary>
    ///     Insert a queue row whose payload cannot deserialize into an AlarmHistorianEvent, stamped
    ///     at an explicit enqueue time.
    /// </summary>
    /// <remarks>
    ///     The timestamp is a parameter rather than <c>DateTime.UtcNow</c> because the drain reads
    ///     in <c>enqueued_at_utc</c> order, and the tests that use this helper care precisely about
    ///     where the corrupt row sits in that order. Three unsynchronised clock reads can land on
    ///     one tick, which would make the interleaving — and therefore the test — a coin flip.
    /// </remarks>
    private void InsertCorruptRow(string alarmId, DateTime enqueuedAt)
    {
        using var conn = Db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            INSERT INTO alarm_sf_events (id, alarm_id, enqueued_at_utc, payload_json, attempt_count)
            VALUES ($id, $alarmId, $enqueued, $payload, 0);
            """;
        cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
        cmd.Parameters.AddWithValue("$alarmId", alarmId);
        cmd.Parameters.AddWithValue("$enqueued", enqueuedAt.ToString("O"));
        // JSON literal "null" round-trips through Deserialize<T> as a null reference.
        cmd.Parameters.AddWithValue("$payload", "null");
        cmd.ExecuteNonQuery();
    }

    /// <summary>
    ///     Monotonic test clock — every read advances one second, so enqueue order is total and
    ///     the drain's <c>ORDER BY enqueued_at_utc</c> is deterministic.
    /// </summary>
    private sealed class TickingClock
    {
        private DateTime _now = new(2026, 7, 21, 0, 0, 0, DateTimeKind.Utc);

        public DateTime Next() => _now = _now.AddSeconds(1);
    }
}
