using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;

namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests;

/// <summary>
/// K4 tests for <see cref="KpiHistoryRecorderActor"/>. The actor's internal
/// <c>SampleTick</c>/<c>PurgeTick</c> messages are exposed to this assembly via
/// <c>InternalsVisibleTo</c> so a tick can be driven deterministically without
/// racing the periodic timer. Hand-rolled fakes (no mocking lib in this test
/// project) record what the recorder hands to the repository.
/// </summary>
public class KpiHistoryRecorderActorTests : TestKit
{
    /// <summary>
    /// A healthy sample source that returns a fixed set of samples, stamping each with the
    /// <c>capturedAtUtc</c> the recorder supplies.
    /// </summary>
    private sealed class HealthySource : IKpiSampleSource
    {
        public string Source => KpiSources.NotificationOutbox;

        public Task<IReadOnlyList<KpiSample>> CollectAsync(
            DateTime capturedAtUtc, CancellationToken cancellationToken = default)
        {
            IReadOnlyList<KpiSample> samples = new List<KpiSample>
            {
                new()
                {
                    Source = Source,
                    Metric = "QueueDepth",
                    Scope = KpiScopes.Global,
                    ScopeKey = null,
                    Value = 7,
                    CapturedAtUtc = capturedAtUtc,
                },
                new()
                {
                    Source = Source,
                    Metric = "ParkedCount",
                    Scope = KpiScopes.Global,
                    ScopeKey = null,
                    Value = 2,
                    CapturedAtUtc = capturedAtUtc,
                },
            };
            return Task.FromResult(samples);
        }
    }

    /// <summary>A source whose <see cref="CollectAsync"/> always throws — must not abort the pass.</summary>
    private sealed class ThrowingSource : IKpiSampleSource
    {
        public string Source => KpiSources.SiteCallAudit;

        public Task<IReadOnlyList<KpiSample>> CollectAsync(
            DateTime capturedAtUtc, CancellationToken cancellationToken = default) =>
            throw new InvalidOperationException("simulated source failure");
    }

    /// <summary>
    /// A source that throws on the first <see cref="CollectAsync"/> call and returns a
    /// healthy sample on every subsequent call. Used to drive a faulted first tick followed
    /// by a healthy second tick on the <em>same</em> actor instance.
    /// </summary>
    private sealed class ThrowOnceSource : IKpiSampleSource
    {
        private int _callCount;

        public string Source => KpiSources.SiteCallAudit;

        public Task<IReadOnlyList<KpiSample>> CollectAsync(
            DateTime capturedAtUtc, CancellationToken cancellationToken = default)
        {
            if (Interlocked.Increment(ref _callCount) == 1)
                throw new InvalidOperationException("simulated first-call source failure");

            IReadOnlyList<KpiSample> samples = new[]
            {
                new KpiSample
                {
                    Source = Source,
                    Metric = "RecoveredSample",
                    Scope = KpiScopes.Global,
                    ScopeKey = null,
                    Value = 1,
                    CapturedAtUtc = capturedAtUtc,
                },
            };
            return Task.FromResult(samples);
        }
    }

    /// <summary>
    /// Recording repository fake. Captures the samples handed to
    /// <see cref="RecordSamplesAsync"/> and the cut-off handed to
    /// <see cref="PurgeOlderThanAsync"/>.
    /// </summary>
    private sealed class RecordingRepository : IKpiHistoryRepository
    {
        private readonly object _gate = new();
        private readonly List<KpiSample> _recorded = new();
        private DateTime? _purgeCutoff;
        private DateTime? _rollupPurgeCutoff;
        private DateTime? _foldFromHourUtc;
        private DateTime? _foldToHourUtc;
        private int _foldCount;
        private readonly List<(DateTime FromHourUtc, DateTime ToHourUtc)> _foldWindows = new();
        private DateTime? _latestRollupHour;
        private int _latestRollupHourQueryCount;

        public IReadOnlyList<KpiSample> Recorded
        {
            get { lock (_gate) { return _recorded.ToArray(); } }
        }

        // PurgeOlderThanAsync runs on a threadpool thread; guard the field with
        // the same _gate lock used by _recorded so test-thread reads are race-free.
        public DateTime? PurgeCutoff
        {
            get { lock (_gate) { return _purgeCutoff; } }
        }

        /// <summary>Cut-off handed to <see cref="PurgeRollupsOlderThanAsync"/> (rollup retention).</summary>
        public DateTime? RollupPurgeCutoff
        {
            get { lock (_gate) { return _rollupPurgeCutoff; } }
        }

        /// <summary>Inclusive lower bound handed to <see cref="FoldHourlyRollupsAsync"/>.</summary>
        public DateTime? FoldFromHourUtc
        {
            get { lock (_gate) { return _foldFromHourUtc; } }
        }

        /// <summary>Exclusive upper bound handed to <see cref="FoldHourlyRollupsAsync"/>.</summary>
        public DateTime? FoldToHourUtc
        {
            get { lock (_gate) { return _foldToHourUtc; } }
        }

        /// <summary>Number of times <see cref="FoldHourlyRollupsAsync"/> was entered.</summary>
        public int FoldCount
        {
            get { lock (_gate) { return _foldCount; } }
        }

        /// <summary>Every fold window handed to <see cref="FoldHourlyRollupsAsync"/>, in call order.</summary>
        public IReadOnlyList<(DateTime FromHourUtc, DateTime ToHourUtc)> FoldWindows
        {
            get { lock (_gate) { return _foldWindows.ToArray(); } }
        }

        public Task RecordSamplesAsync(
            IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
        {
            lock (_gate)
            {
                _recorded.AddRange(samples);
            }
            return Task.CompletedTask;
        }

        public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
        {
            lock (_gate) { _purgeCutoff = before; }
            return Task.FromResult(0);
        }

        public Task FoldHourlyRollupsAsync(
            DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
        {
            lock (_gate)
            {
                _foldFromHourUtc = fromHourUtc;
                _foldToHourUtc = toHourUtc;
                _foldWindows.Add((fromHourUtc, toHourUtc));
                _foldCount++;
            }
            return Task.CompletedTask;
        }

        public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
        {
            lock (_gate) { _rollupPurgeCutoff = before; }
            return Task.CompletedTask;
        }

        /// <summary>Watermark the fake returns from <see cref="GetLatestRollupHourAsync"/> (settable).</summary>
        public DateTime? LatestRollupHour
        {
            get { lock (_gate) { return _latestRollupHour; } }
            set { lock (_gate) { _latestRollupHour = value; } }
        }

        /// <summary>Number of times the backfill plan pass queried the rollup watermark.</summary>
        public int LatestRollupHourQueryCount
        {
            get { lock (_gate) { return _latestRollupHourQueryCount; } }
        }

        public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default)
        {
            lock (_gate)
            {
                _latestRollupHourQueryCount++;
                return Task.FromResult(_latestRollupHour);
            }
        }
    }

    /// <summary>
    /// Repository fake whose <see cref="RecordSamplesAsync"/> blocks on a manual-reset gate
    /// until the test releases it, holding a sample pass "in flight" so an overlapping-tick
    /// scenario can be driven deterministically. Counts how many times the write was entered.
    /// </summary>
    private sealed class GatedRepository : IKpiHistoryRepository
    {
        private readonly ManualResetEventSlim _release = new(initialState: false);
        private int _writeCount;

        /// <summary>Number of times <see cref="RecordSamplesAsync"/> has been entered.</summary>
        public int WriteCount => Volatile.Read(ref _writeCount);

        /// <summary>Releases all blocked writes so the gated passes can complete.</summary>
        public void Release() => _release.Set();

        public Task RecordSamplesAsync(
            IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
        {
            Interlocked.Increment(ref _writeCount);
            // Block on a threadpool thread (PipeTo runs the pass off the actor thread), holding
            // the pass in flight until the test opens the gate.
            return Task.Run(() => _release.Wait(cancellationToken), cancellationToken);
        }

        public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.FromResult(0);

        public Task FoldHourlyRollupsAsync(
            DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
            Task.FromResult<DateTime?>(null);
    }

    /// <summary>
    /// Repository fake whose <see cref="FoldHourlyRollupsAsync"/> blocks on a manual-reset gate
    /// until the test releases it, holding a rollup fold "in flight" so an overlapping-tick
    /// scenario can be driven deterministically. Counts how many times the fold was entered.
    /// </summary>
    private sealed class GatedFoldRepository : IKpiHistoryRepository
    {
        private readonly ManualResetEventSlim _release = new(initialState: false);
        private int _foldCount;

        /// <summary>Number of times <see cref="FoldHourlyRollupsAsync"/> has been entered.</summary>
        public int FoldCount => Volatile.Read(ref _foldCount);

        /// <summary>Releases all blocked folds so the gated passes can complete.</summary>
        public void Release() => _release.Set();

        public Task RecordSamplesAsync(
            IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.FromResult(0);

        public Task FoldHourlyRollupsAsync(
            DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
        {
            Interlocked.Increment(ref _foldCount);
            // Block on a threadpool thread (PipeTo runs the pass off the actor thread), holding
            // the fold in flight until the test opens the gate.
            return Task.Run(() => _release.Wait(cancellationToken), cancellationToken);
        }

        public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
            Task.FromResult<DateTime?>(null);
    }

    /// <summary>
    /// Repository fake whose <see cref="FoldHourlyRollupsAsync"/> throws on the first call and
    /// succeeds (recording the window) on every subsequent call — drives a faulted first fold
    /// followed by a healthy second fold on the same actor instance.
    /// </summary>
    private sealed class ThrowOnceFoldRepository : IKpiHistoryRepository
    {
        private readonly object _gate = new();
        private int _foldCount;
        private DateTime? _lastFoldToHourUtc;

        /// <summary>Number of times <see cref="FoldHourlyRollupsAsync"/> has been entered (incl. the throw).</summary>
        public int FoldCount
        {
            get { lock (_gate) { return _foldCount; } }
        }

        /// <summary>Exclusive upper bound of the most recent successful fold.</summary>
        public DateTime? LastFoldToHourUtc
        {
            get { lock (_gate) { return _lastFoldToHourUtc; } }
        }

        public Task RecordSamplesAsync(
            IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.FromResult(0);

        public Task FoldHourlyRollupsAsync(
            DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
        {
            lock (_gate)
            {
                _foldCount++;
                if (_foldCount == 1)
                    throw new InvalidOperationException("simulated first-fold failure");
                _lastFoldToHourUtc = toHourUtc;
            }
            return Task.CompletedTask;
        }

        public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
            Task.FromResult<DateTime?>(null);
    }

    /// <summary>
    /// Repository fake for the backfill-slice interleaving test. Classifies each fold as a backfill
    /// slice (24 h window) or a periodic fold (the distinct <c>RollupLookbackHours</c>-wide window)
    /// by window width. Backfill slices complete immediately (counted); the FIRST periodic fold
    /// blocks in flight until released, so the test can observe that a periodic fold claimed the
    /// fold path between backfill slices while backfill slices remained.
    /// </summary>
    private sealed class InterleaveFoldRepository : IKpiHistoryRepository
    {
        private readonly TimeSpan _periodicWindowWidth;
        private readonly object _gate = new();
        private readonly SemaphoreSlim _firstPeriodicRelease = new(initialCount: 0);
        private int _backfillFoldCount;
        private int _periodicFoldCount;
        private int _backfillCountAtFirstPeriodic = -1;

        public InterleaveFoldRepository(TimeSpan periodicWindowWidth) =>
            _periodicWindowWidth = periodicWindowWidth;

        /// <summary>Number of backfill-slice folds (24 h windows) entered so far.</summary>
        public int BackfillFoldCount { get { lock (_gate) { return _backfillFoldCount; } } }

        /// <summary>Number of periodic folds (the lookback-width window) entered so far.</summary>
        public int PeriodicFoldCount { get { lock (_gate) { return _periodicFoldCount; } } }

        /// <summary>Backfill-slice count observed at the instant the first periodic fold entered.</summary>
        public int BackfillCountAtFirstPeriodic { get { lock (_gate) { return _backfillCountAtFirstPeriodic; } } }

        /// <summary>Releases the first (held-in-flight) periodic fold so the deferred backfill resumes.</summary>
        public void ReleaseFirstPeriodic() => _firstPeriodicRelease.Release();

        public Task RecordSamplesAsync(
            IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.FromResult(0);

        public Task FoldHourlyRollupsAsync(
            DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
        {
            var isPeriodic = (toHourUtc - fromHourUtc) == _periodicWindowWidth;
            if (isPeriodic)
            {
                bool blockThis;
                lock (_gate)
                {
                    _periodicFoldCount++;
                    blockThis = _periodicFoldCount == 1;
                    if (blockThis)
                    {
                        _backfillCountAtFirstPeriodic = _backfillFoldCount;
                    }
                }

                // Hold the first periodic fold in flight (on a threadpool thread — PipeTo runs the
                // pass off the actor thread) so the between-slice interleave state is observable.
                return blockThis
                    ? Task.Run(() => _firstPeriodicRelease.Wait(cancellationToken), cancellationToken)
                    : Task.CompletedTask;
            }

            lock (_gate) { _backfillFoldCount++; }
            return Task.CompletedTask;
        }

        public Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
            string source, string metric, string scope, string? scopeKey,
            DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
            Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());

        public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
            Task.CompletedTask;

        public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
            Task.FromResult<DateTime?>(null);
    }

    private IServiceProvider BuildServiceProvider(
        IKpiHistoryRepository repository, params IKpiSampleSource[] sources)
    {
        var services = new ServiceCollection();
        // Mirror production: sources + repository are scoped, so the recorder opens a fresh
        // scope per tick and resolves there.
        foreach (var source in sources)
        {
            var captured = source;
            services.AddScoped(_ => captured);
        }
        services.AddScoped(_ => repository);
        return services.BuildServiceProvider();
    }

    /// <summary>
    /// Creates the recorder with both timers set to a long interval so neither periodic timer
    /// fires during a test — ticks are sent manually instead.
    /// </summary>
    private IActorRef CreateActor(IServiceProvider serviceProvider, KpiHistoryOptions? options = null)
    {
        return Sys.ActorOf(Props.Create(() => new KpiHistoryRecorderActor(
            serviceProvider,
            options ?? new KpiHistoryOptions
            {
                SampleInterval = TimeSpan.FromHours(1),
                PurgeInterval = TimeSpan.FromHours(1),
            },
            NullLogger<KpiHistoryRecorderActor>.Instance)));
    }

    [Fact]
    public void SampleTick_WritesHealthySourceSamples_AndThrowingSourceDoesNotAbortTick()
    {
        var repository = new RecordingRepository();
        // Order the throwing source FIRST so the test also proves a throw early in the
        // enumeration does not suppress a later healthy source.
        var sp = BuildServiceProvider(repository, new ThrowingSource(), new HealthySource());
        var actor = CreateActor(sp);

        actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);

        AwaitAssert(
            () =>
            {
                // The healthy source's two samples were written despite the throwing source.
                Assert.Equal(2, repository.Recorded.Count);
                Assert.Contains(repository.Recorded, s => s.Metric == "QueueDepth" && s.Value == 7);
                Assert.Contains(repository.Recorded, s => s.Metric == "ParkedCount" && s.Value == 2);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void PurgeTick_CallsPurgeWithCutoff_AtUtcNowMinusRetentionDays()
    {
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        const int retentionDays = 30;
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RetentionDays = retentionDays,
        });

        actor.Tell(KpiHistoryRecorderActor.PurgeTick.Instance);

        AwaitAssert(
            () =>
            {
                Assert.NotNull(repository.PurgeCutoff);
                var expected = DateTime.UtcNow - TimeSpan.FromDays(retentionDays);
                Assert.True(
                    Math.Abs((repository.PurgeCutoff!.Value - expected).TotalMinutes) < 1.0,
                    $"purge cutoff {repository.PurgeCutoff:o} should be within 1 minute of {expected:o}");
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void FaultedTick_DoesNotCrashActor_AndSubsequentTickStillRuns()
    {
        // ThrowOnceSource throws on the first CollectAsync call and returns a healthy
        // sample on every subsequent call.  This lets us send two ticks to the SAME
        // actor instance and verify that:
        //   • The first tick (faulted source) records nothing but does not crash the actor.
        //   • The second tick reaches the same actor and records the recovered sample,
        //     proving the singleton's message loop is still alive after a faulted pass.
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new ThrowOnceSource());
        var actor = CreateActor(sp);

        // First tick: source throws on first call — caught per-source, nothing written, actor lives.
        actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
        AwaitAssert(
            () => Assert.Empty(repository.Recorded),
            duration: TimeSpan.FromSeconds(2),
            interval: TimeSpan.FromMilliseconds(50));

        // Second tick to the SAME actor: source now returns a healthy sample. Re-send the tick
        // on each poll so we don't race the (asynchronous) SampleComplete that lowers the
        // in-flight guard after the faulted first pass — a tick that lands before the guard
        // clears is harmlessly skipped, and the next poll's tick runs. The recovered sample
        // being recorded proves the actor's message loop is still alive after a faulted pass.
        // (>= 1 rather than exactly-1: a later re-sent tick could run an extra recovering pass
        // once the guard clears, which is not what this test pins — KH-001's one-pass-per-tick
        // guard is pinned by OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass.)
        AwaitAssert(
            () =>
            {
                actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
                Assert.NotEmpty(repository.Recorded);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass()
    {
        // KpiHistory-001 regression: the in-flight guard must coalesce a tick that arrives
        // while a prior sample pass is still awaiting its DB write. With a gated repository
        // holding the first write open, a second SampleTick must NOT spawn a second pass —
        // so RecordSamplesAsync is entered exactly once until the gate is released.
        var repository = new GatedRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp);

        // First tick: raises the guard, enters the (gated, blocking) write — held in flight.
        actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
        AwaitAssert(
            () => Assert.Equal(1, repository.WriteCount),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        // Second tick while the first pass is still in flight: must be skipped by the guard.
        actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);

        // Give the second tick ample time to (wrongly) start a pass; the write count must
        // stay at 1, proving no second concurrent pass was launched.
        Assert.Equal(1, repository.WriteCount);
        Thread.Sleep(300);
        Assert.Equal(1, repository.WriteCount);

        // Release the gate so the first pass completes and the guard is lowered; a fresh
        // tick must now run a new pass (guard correctly reset, not stuck). Re-send the tick on
        // each poll so we don't race the (asynchronous) SampleComplete that lowers the guard —
        // a tick that lands before the guard clears is harmlessly skipped, the next one runs.
        repository.Release();
        AwaitAssert(
            () =>
            {
                actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
                Assert.Equal(2, repository.WriteCount);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    // =====================================================================
    // Hourly rollup fold (Task 4)
    // =====================================================================

    [Fact]
    public void RollupTick_FoldsTrailingLookback_WithInProgressHourExcluded()
    {
        // The fold's upper bound must be the CURRENT hour start (exclusive) so the in-progress
        // hour is never folded, and its lower bound must be RollupLookbackHours earlier so a
        // missed/failover tick self-heals via the idempotent upsert.
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        const int lookbackHours = 3;
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(1),
            RollupLookbackHours = lookbackHours,
        });

        actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);

        AwaitAssert(
            () =>
            {
                Assert.NotNull(repository.FoldToHourUtc);
                Assert.NotNull(repository.FoldFromHourUtc);

                var toHour = repository.FoldToHourUtc!.Value;
                var fromHour = repository.FoldFromHourUtc!.Value;

                // toHourUtc is the truncated current hour (in-progress hour excluded).
                Assert.Equal(DateTimeKind.Utc, toHour.Kind);
                Assert.Equal(0, toHour.Minute);
                Assert.Equal(0, toHour.Second);
                Assert.Equal(0, toHour.Millisecond);

                var expectedToHour = new DateTime(
                    DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
                    DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
                // Within one hour of "now truncated" (guards a rare hour-boundary crossing mid-test).
                Assert.True(
                    Math.Abs((toHour - expectedToHour).TotalHours) <= 1.0,
                    $"fold toHour {toHour:o} should be the current hour start {expectedToHour:o}");

                // fromHour is exactly lookbackHours before toHour.
                Assert.Equal(toHour - TimeSpan.FromHours(lookbackHours), fromHour);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void PurgeTick_PurgesBothRawSamplesAndRollups_WithTheirOwnCutoffs()
    {
        // The daily purge must drop raw samples at RetentionDays AND rollups at the longer
        // RollupRetentionDays — each with its own cut-off, both best-effort.
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        const int retentionDays = 90;
        const int rollupRetentionDays = 365;
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RetentionDays = retentionDays,
            RollupRetentionDays = rollupRetentionDays,
        });

        actor.Tell(KpiHistoryRecorderActor.PurgeTick.Instance);

        AwaitAssert(
            () =>
            {
                Assert.NotNull(repository.PurgeCutoff);
                Assert.NotNull(repository.RollupPurgeCutoff);

                var expectedRaw = DateTime.UtcNow - TimeSpan.FromDays(retentionDays);
                var expectedRollup = DateTime.UtcNow - TimeSpan.FromDays(rollupRetentionDays);

                Assert.True(
                    Math.Abs((repository.PurgeCutoff!.Value - expectedRaw).TotalMinutes) < 1.0,
                    $"raw purge cutoff {repository.PurgeCutoff:o} should be within 1 minute of {expectedRaw:o}");
                Assert.True(
                    Math.Abs((repository.RollupPurgeCutoff!.Value - expectedRollup).TotalMinutes) < 1.0,
                    $"rollup purge cutoff {repository.RollupPurgeCutoff:o} should be within 1 minute of {expectedRollup:o}");
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void OverlappingRollupTick_WhileFirstFoldInFlight_DoesNotStartSecondFold()
    {
        // The _rollupInFlight guard must coalesce a tick that arrives while a prior fold is
        // still running. With a gated fold held open, a second RollupTick must NOT spawn a
        // second fold — so FoldHourlyRollupsAsync is entered exactly once until the gate opens.
        var repository = new GatedFoldRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp);

        // First tick: raises the guard, enters the (gated, blocking) fold — held in flight.
        actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
        AwaitAssert(
            () => Assert.Equal(1, repository.FoldCount),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        // Second tick while the first fold is still in flight: must be skipped by the guard.
        actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);

        Assert.Equal(1, repository.FoldCount);
        Thread.Sleep(300);
        Assert.Equal(1, repository.FoldCount);

        // Release the gate so the first fold completes and the guard is lowered; a fresh tick
        // must now run a new fold (guard correctly reset, not stuck). Re-send the tick on each
        // poll so we don't race the asynchronous RollupComplete that lowers the guard.
        repository.Release();
        AwaitAssert(
            () =>
            {
                actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
                Assert.Equal(2, repository.FoldCount);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void FaultedRollupFold_DoesNotCrashActor_AndSubsequentTickStillFolds()
    {
        // A fold that throws must be caught (best-effort) — it must neither crash the singleton
        // nor stop future rollup ticks. ThrowOnceFoldRepository throws on the first fold and
        // succeeds afterward, so a healthy second fold on the SAME actor proves the loop lives.
        var repository = new ThrowOnceFoldRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp);

        // First tick: fold throws — caught, actor lives, no successful fold recorded.
        actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
        AwaitAssert(
            () => Assert.Null(repository.LastFoldToHourUtc),
            duration: TimeSpan.FromSeconds(2),
            interval: TimeSpan.FromMilliseconds(50));

        // Second tick to the SAME actor: fold now succeeds. Re-send on each poll so we don't
        // race the asynchronous RollupComplete that lowers the guard after the faulted fold.
        AwaitAssert(
            () =>
            {
                actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
                Assert.NotNull(repository.LastFoldToHourUtc);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    // =====================================================================
    // One-shot rollup backfill (Task 6)
    // =====================================================================

    [Fact]
    public void BackfillTick_FoldsFullRetentionWindow_Once()
    {
        // The one-shot backfill must fold the WHOLE raw-retention window once, so long-range
        // (30 d/90 d) charts aren't blank until enough wall-clock passes after deploy. It now
        // does so in bounded <=24 h slices (R1) rather than a single unbounded fold: the union
        // of the slice windows must cover exactly [truncate(now) − RetentionDays, truncate(now)),
        // each slice must be <=24 h, and the slices must be contiguous and oldest-first. A second
        // BackfillTick to the same actor must be a no-op (the _backfillDone latch makes the whole
        // pass run at most once per actor lifetime).
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        const int retentionDays = 5;
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(1),
            RetentionDays = retentionDays,
        });

        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);

        AwaitAssert(
            () =>
            {
                var windows = repository.FoldWindows;
                // RetentionDays = 5 → exactly 5 contiguous 24 h slices.
                Assert.Equal(retentionDays, windows.Count);

                var expectedToHour = new DateTime(
                    DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
                    DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
                var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays);

                // Oldest-first, contiguous, each <=24 h, union == full retention window.
                Assert.Equal(expectedFromHour, windows[0].FromHourUtc);
                Assert.Equal(expectedToHour, windows[^1].ToHourUtc);
                for (var i = 0; i < windows.Count; i++)
                {
                    Assert.True(windows[i].ToHourUtc - windows[i].FromHourUtc <= TimeSpan.FromHours(24),
                        $"slice {i} width {windows[i].ToHourUtc - windows[i].FromHourUtc} exceeds 24 h");
                    if (i > 0)
                    {
                        Assert.Equal(windows[i - 1].ToHourUtc, windows[i].FromHourUtc); // contiguous
                    }
                }
            },
            duration: TimeSpan.FromSeconds(5),
            interval: TimeSpan.FromMilliseconds(50));

        // Second BackfillTick to the SAME actor: the once-only latch must suppress the whole pass,
        // so no further slices are folded. (No periodic RollupTick is sent, and the periodic/backfill
        // auto-timers don't fire within this window, so the fold windows are driven solely by the
        // backfill under test.)
        var foldedCount = repository.FoldWindows.Count;
        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
        Thread.Sleep(300);
        Assert.Equal(foldedCount, repository.FoldWindows.Count);
    }

    [Fact]
    public void Backfill_SlicesRetentionWindow_IntoBoundedDayFolds_OldestFirst()
    {
        // RetentionDays = 3 → 3 fold calls, each window <=24 h, contiguous, oldest-first, whose
        // union is exactly [TruncateToHour(now) − 3 d, TruncateToHour(now)). Pre-R1 a single fold
        // call spanned the whole 3-day window (arch-review 04 round 2, R1).
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        const int retentionDays = 3;
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(1),
            RetentionDays = retentionDays,
        });

        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);

        AwaitAssert(
            () =>
            {
                var windows = repository.FoldWindows;
                Assert.Equal(retentionDays, windows.Count); // 3 × 24 h slices

                var expectedToHour = new DateTime(
                    DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
                    DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
                var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays);

                Assert.Equal(expectedFromHour, windows[0].FromHourUtc);   // oldest first
                Assert.Equal(expectedToHour, windows[^1].ToHourUtc);      // newest last
                for (var i = 0; i < windows.Count; i++)
                {
                    Assert.True(windows[i].ToHourUtc - windows[i].FromHourUtc <= TimeSpan.FromHours(24),
                        $"slice {i} exceeds 24 h");
                    if (i > 0)
                    {
                        Assert.Equal(windows[i - 1].ToHourUtc, windows[i].FromHourUtc); // contiguous
                    }
                }
            },
            duration: TimeSpan.FromSeconds(5),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public async Task PeriodicRollupTick_Interleaves_BetweenBackfillSlices()
    {
        // A periodic fold must be able to claim the fold path BETWEEN backfill slices: while the
        // historical backfill runs slice-by-slice, a RollupTick that lands in a between-slice gap
        // runs its (recent-window) fold and the remaining backfill slices defer behind it, then
        // ALL backfill slices still complete afterwards. Pre-R1 _backfillInFlight blocked every
        // periodic fold for the whole (single-fold) pass, so no interleaving was possible
        // (arch-review 04 round 2, R1).
        const int retentionDays = 8;   // 8 backfill slices of 24 h
        const int lookbackHours = 2;   // periodic fold window width — distinct from the 24 h slices
        var repository = new InterleaveFoldRepository(TimeSpan.FromHours(lookbackHours));
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(24), // periodic auto-timer won't fire during the test
            RetentionDays = retentionDays,
            RollupLookbackHours = lookbackHours,
        });

        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);

        // Continuously offer periodic ticks; one will win a between-slice gap and interleave.
        using var stopFlood = new CancellationTokenSource();
        var flood = Task.Run(() =>
        {
            while (!stopFlood.IsCancellationRequested)
            {
                actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
                Thread.Sleep(1);
            }
        });

        try
        {
            // A periodic fold interleaved BEFORE the backfill finished all its slices.
            AwaitAssert(
                () =>
                {
                    Assert.True(repository.PeriodicFoldCount >= 1, "no periodic fold interleaved");
                    Assert.True(
                        repository.BackfillCountAtFirstPeriodic < retentionDays,
                        "the periodic fold did not interleave before the backfill completed");
                },
                duration: TimeSpan.FromSeconds(10),
                interval: TimeSpan.FromMilliseconds(20));
        }
        finally
        {
            stopFlood.Cancel();
            await flood;
        }

        // The first periodic fold is held in flight, deferring the remaining backfill slices;
        // release it so the deferred backfill resumes and ALL slices still complete.
        repository.ReleaseFirstPeriodic();
        AwaitAssert(
            () => Assert.Equal(retentionDays, repository.BackfillFoldCount),
            duration: TimeSpan.FromSeconds(10),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void BackfillInFlight_SkipsPeriodicRollupTick()
    {
        // While a backfill slice fold is in flight, a periodic RollupTick must be skipped so the
        // two idempotent upserts never race on overlapping rows (unchanged semantics — a RollupTick
        // arriving while a *slice* is in flight is still skipped). With a gated fold holding the
        // single backfill slice open, a RollupTick must NOT enter a second fold — the fold count
        // stays at 1 until the gate is released. RetentionDays = 1 → exactly one 24 h backfill slice.
        var repository = new GatedFoldRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(1),
            RetentionDays = 1,
        });

        // Backfill tick: raises the backfill guard, enters the (gated, blocking) fold — held in flight.
        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
        AwaitAssert(
            () => Assert.Equal(1, repository.FoldCount),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        // Periodic rollup tick while the backfill is in flight: must be skipped by the guard.
        actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
        Assert.Equal(1, repository.FoldCount);
        Thread.Sleep(300);
        Assert.Equal(1, repository.FoldCount);

        // Release the gate so the backfill completes and its guard is lowered; a fresh periodic
        // tick must now run a new fold (guards correctly reset, not stuck). Re-send on each poll so
        // we don't race the asynchronous BackfillSliceComplete that lowers the guard.
        repository.Release();
        AwaitAssert(
            () =>
            {
                actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
                Assert.Equal(2, repository.FoldCount);
            },
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    [Fact]
    public void Backfill_Skips_WhenRollupsAlreadyCurrent()
    {
        // Watermark within RollupLookbackHours of now (the common failover case): the backfill
        // must be skipped entirely — ZERO folds, the done-latch set — because the periodic fold's
        // lookback window already covers everything from the watermark forward (arch-review 04
        // round 2, R1).
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(1),
            RetentionDays = 90,
            RollupLookbackHours = 3,
        });

        // Rollups current through one hour ago — well inside the 3 h lookback tail.
        var nowHour = new DateTime(
            DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
            DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
        repository.LatestRollupHour = nowHour - TimeSpan.FromHours(1);

        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);

        // The plan pass must consult the watermark…
        AwaitAssert(
            () => Assert.True(repository.LatestRollupHourQueryCount >= 1),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        // …and then skip: no slices are ever folded.
        Thread.Sleep(300);
        Assert.Equal(0, repository.FoldCount);

        // A second BackfillTick is a no-op (the done-latch is set).
        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
        Thread.Sleep(300);
        Assert.Equal(0, repository.FoldCount);
    }

    [Fact]
    public void Backfill_ShrinksToWatermark_InsteadOfRetentionFloor()
    {
        // RetentionDays = 90 but the watermark is only 2 days old: the first slice must start at
        // the WATERMARK hour (inclusive — re-folding the newest already-folded hour is a cheap
        // idempotent safety margin), NOT 90 days ago, and there must be <= 3 slices total
        // (arch-review 04 round 2, R1).
        var repository = new RecordingRepository();
        var sp = BuildServiceProvider(repository, new HealthySource());
        var actor = CreateActor(sp, new KpiHistoryOptions
        {
            SampleInterval = TimeSpan.FromHours(1),
            PurgeInterval = TimeSpan.FromHours(1),
            RollupInterval = TimeSpan.FromHours(1),
            RetentionDays = 90,
            RollupLookbackHours = 3,
        });

        var nowHour = new DateTime(
            DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
            DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
        var watermark = nowHour - TimeSpan.FromDays(2);
        repository.LatestRollupHour = watermark;

        actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);

        AwaitAssert(
            () =>
            {
                var windows = repository.FoldWindows;
                Assert.NotEmpty(windows);
                // Shrunk to the un-rolled tail: first slice starts AT the watermark, not 90 d ago.
                Assert.Equal(watermark, windows[0].FromHourUtc);
                Assert.True(windows.Count <= 3, $"expected <= 3 slices from the 2-day tail, got {windows.Count}");
            },
            duration: TimeSpan.FromSeconds(5),
            interval: TimeSpan.FromMilliseconds(50));
    }
}
