using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
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;

namespace ZB.MOM.WW.ScadaBridge.KpiHistory;

/// <summary>
/// Central cluster-singleton ("KPI History &amp; Trends") that drives the
/// KPI sampling backbone. On a periodic timer (default 60 s) it:
/// <list type="number">
/// <item>Stamps a single <c>capturedAtUtc</c> for the pass.</item>
/// <item>Fans out to every DI-registered <see cref="IKpiSampleSource"/>,
///       collecting each source's point-in-time samples.</item>
/// <item>Bulk-writes the combined batch through
///       <see cref="IKpiHistoryRepository.RecordSamplesAsync"/>.</item>
/// </list>
/// A separate daily timer (default 1 d) runs the retention purge, dropping raw
/// sample rows older than <see cref="KpiHistoryOptions.RetentionDays"/> via
/// <see cref="IKpiHistoryRepository.PurgeOlderThanAsync"/> and hourly rollup rows
/// older than the longer <see cref="KpiHistoryOptions.RollupRetentionDays"/> via
/// <see cref="IKpiHistoryRepository.PurgeRollupsOlderThanAsync"/>. A third hourly
/// timer (default 1 h) folds the trailing
/// <see cref="KpiHistoryOptions.RollupLookbackHours"/> hours of raw samples into the
/// <c>KpiRollupHourly</c> table via
/// <see cref="IKpiHistoryRepository.FoldHourlyRollupsAsync"/> (idempotent upsert,
/// in-progress hour excluded), so long-range trend charts read a pre-thinned
/// series. A one-shot backfill timer additionally folds the whole raw-retention
/// window of already-recorded samples once shortly after start, so 30 d/90 d
/// charts aren't blank until enough wall-clock passes after deploy; it reuses the
/// same idempotent fold and runs at most once per actor lifetime. The backfill is
/// sliced into bounded 24 h fold windows (oldest-first) rather than one unbounded
/// pass, and lowers its in-flight guard between slices so periodic folds interleave
/// — periodic folds and backfill slices strictly alternate through the single
/// <c>_rollupInFlight</c>/<c>_backfillInFlight</c> gate pair, so the two idempotent
/// upserts never race on overlapping rows (arch-review 04 round 2, R1).
/// </summary>
/// <remarks>
/// <para>
/// <b>Best-effort.</b> KPI history is observability — it must NEVER disrupt the
/// system it observes. Every fault is contained: a throwing
/// <see cref="IKpiSampleSource"/> is caught per-source so it neither aborts the
/// pass nor suppresses the other sources; a throwing repository write/purge is
/// caught and logged. No exception escapes either tick handler, so the singleton
/// stays alive across transient DB outages and misbehaving sources.
/// </para>
/// <para>
/// <b>Off-thread work.</b> The actual sampling and purge I/O runs off the actor
/// thread; the result is piped back to <see cref="Self"/> via
/// <see cref="PipeToSupport.PipeTo{T}(Task{T}, ICanTell, IActorRef, System.Func{T, object}, System.Func{System.Exception, object})"/>
/// so the mailbox is never blocked while a DB round-trip is in flight. This
/// mirrors the <see cref="ZB.MOM.WW.ScadaBridge.NotificationOutbox.NotificationOutboxActor"/>
/// timer + scope-per-tick + PipeTo pattern.
/// </para>
/// <para>
/// <b>DI scopes.</b> <see cref="IKpiSampleSource"/>s and
/// <see cref="IKpiHistoryRepository"/> are scoped EF Core-backed services; the
/// recorder opens a fresh <see cref="IServiceScope"/> per tick and resolves
/// there, asynchronously disposing it (<c>CreateAsyncScope</c>) so EF Core's
/// async connection cleanup does not block.
/// </para>
/// <para>
/// <b>Singleton wiring.</b> The <c>Props</c> is built in the Host on the
/// active central node with the constructor args resolved from DI — this actor
/// has no knowledge of <c>Akka.Cluster.Tools</c>, mirroring the Notification
/// Outbox singleton split.
/// </para>
/// </remarks>
public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
{
    private const string SampleTimerKey = "kpi-sample";
    private const string PurgeTimerKey = "kpi-purge";
    private const string RollupTimerKey = "kpi-rollup";
    private const string BackfillTimerKey = "kpi-rollup-backfill";

    /// <summary>
    /// Initial delay before the one-shot rollup backfill fires (see
    /// <see cref="HandleBackfillTick"/>). Deliberately a touch longer than the periodic rollup
    /// timer's own <c>10 s</c> initial delay so that, when both are armed at start, the periodic
    /// tick claims the fold path first and the backfill simply defers (the two folds are
    /// idempotent upserts, but they must not race on overlapping rows). Also comfortably beyond
    /// the sub-second/second-scale <c>AwaitAssert</c> windows used by the unit tests, so the
    /// one-shot never auto-fires mid-test — tests drive <see cref="BackfillTick"/> by hand.
    /// </summary>
    private static readonly TimeSpan BackfillInitialDelay = TimeSpan.FromSeconds(15);

    private readonly IServiceProvider _serviceProvider;
    private readonly KpiHistoryOptions _options;
    private readonly ILogger<KpiHistoryRecorderActor> _logger;

    /// <summary>
    /// Lifecycle-scoped cancellation source, cancelled in <see cref="PostStop"/> so any
    /// in-flight sample/purge pass observes a coordinated shutdown / failover promptly
    /// instead of blocking the singleton handover for a full DB round-trip timeout.
    /// </summary>
    private CancellationTokenSource? _shutdownCts;

    /// <summary>
    /// In-flight guard for the sample loop. Set true at the start of a sample pass and cleared
    /// when the pass's <see cref="SampleComplete"/> arrives. While true, further
    /// <see cref="SampleTick"/>s are skipped so passes never overlap — Akka periodic timers
    /// enqueue (not coalesce) missed ticks, so without this guard a pass running longer than
    /// <see cref="KpiHistoryOptions.SampleInterval"/> (slow/recovering DB) would let each
    /// subsequent tick spawn another concurrent pass, amplifying load on the struggling store
    /// and double-writing samples for overlapping windows. Mirrors the
    /// <see cref="ZB.MOM.WW.ScadaBridge.NotificationOutbox.NotificationOutboxActor"/> dispatch
    /// in-flight guard the timer pattern is modelled on.
    /// </summary>
    private bool _sampleInFlight;

    /// <summary>
    /// In-flight guard for the hourly rollup fold. Set true at the start of a fold pass and
    /// cleared when the pass's <see cref="RollupComplete"/> arrives. While true, further
    /// <see cref="RollupTick"/>s are skipped so folds never overlap — the fold is a
    /// group-by-and-upsert over a lookback window that can outlast the (default 1 h)
    /// <see cref="KpiHistoryOptions.RollupInterval"/> on a slow/recovering store; without this
    /// guard a subsequent tick would launch a second concurrent fold contending for the same
    /// upsert key. Mirrors <see cref="_sampleInFlight"/>.
    /// </summary>
    private bool _rollupInFlight;

    /// <summary>
    /// Width of one backfill fold slice (24 h). The repository fold materializes its whole
    /// window in memory, so the one-shot backfill must never hand it the full raw-retention
    /// window (90 d ≈ tens of millions of rows at fleet volume — arch-review 04 round 2, R1).
    /// One day per fold bounds each pass to roughly what a steady-state day writes, and the
    /// guard is lowered between slices so periodic folds interleave instead of stalling
    /// behind the historical pass.
    /// </summary>
    private static readonly TimeSpan BackfillSliceWidth = TimeSpan.FromHours(24);

    /// <summary>
    /// Queue of remaining backfill fold slices (each a bounded <see cref="BackfillSliceWidth"/>
    /// window), oldest-first. Built once when the backfill plan is enumerated and drained one
    /// slice per fold; a periodic <see cref="RollupTick"/> may claim the fold path between slices
    /// (the two idempotent upserts strictly alternate through the
    /// <see cref="_rollupInFlight"/>/<see cref="_backfillInFlight"/> gate pair, never racing on
    /// overlapping rows — arch-review 04 round 2, R1).
    /// </summary>
    private readonly Queue<(DateTime FromHourUtc, DateTime ToHourUtc)> _backfillSlices = new();

    /// <summary>
    /// In-flight guard for a single backfill fold slice. Set true when a slice fold is launched
    /// and cleared when its <see cref="BackfillSliceComplete"/> arrives. While true, periodic
    /// <see cref="RollupTick"/>s are skipped (like <see cref="_rollupInFlight"/>) so a backfill
    /// slice never runs concurrently with a periodic lookback fold on the same upsert rows; it is
    /// lowered BETWEEN slices so periodic folds interleave instead of stalling behind the whole
    /// historical pass.
    /// </summary>
    private bool _backfillInFlight;

    /// <summary>
    /// Once-only latch for the backfill. Set true when the backfill fold completes (success or
    /// fault) so it runs at most once per actor lifetime — a fresh singleton on failover starts a
    /// new instance and re-runs the (idempotent) backfill, which is the intended self-heal. Set on
    /// completion rather than launch so that if the actor is torn down mid-backfill the next
    /// lifetime still attempts it.
    /// </summary>
    private bool _backfillDone;

    /// <summary>Akka timer scheduler, assigned by the actor system via <see cref="IWithTimers"/>.</summary>
    public ITimerScheduler Timers { get; set; } = null!;

    /// <summary>
    /// Initializes the recorder with its dependencies and registers the tick handlers.
    /// </summary>
    /// <param name="serviceProvider">DI service provider used to open a scope per tick for the sample sources and repository.</param>
    /// <param name="options">KPI history configuration options.</param>
    /// <param name="logger">Logger for this actor.</param>
    public KpiHistoryRecorderActor(
        IServiceProvider serviceProvider,
        KpiHistoryOptions options,
        ILogger<KpiHistoryRecorderActor> logger)
    {
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));

        Receive<SampleTick>(_ => HandleSampleTick());
        Receive<SampleComplete>(_ => _sampleInFlight = false); // lower the in-flight guard (success or fault)
        Receive<PurgeTick>(_ => HandlePurgeTick());
        Receive<PurgeComplete>(_ => { }); // best-effort: no actor state to reset on completion
        Receive<RollupTick>(_ => HandleRollupTick());
        Receive<RollupComplete>(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault)
        Receive<BackfillTick>(_ => HandleBackfillTick());
        Receive<BackfillPlan>(HandleBackfillPlan);
        Receive<BackfillSliceComplete>(_ =>
        {
            _backfillInFlight = false; // release the fold path BETWEEN slices (R1)
            if (_backfillSlices.Count == 0)
            {
                _backfillDone = true; // latch: at most once per actor lifetime (unchanged semantics)
                _logger.LogInformation("KPI rollup backfill completed — all slices folded.");
                return;
            }

            // Next slice via the timer, not inline: a RollupTick already queued in the mailbox
            // is processed first, so periodic folds interleave; the existing _rollupInFlight
            // deferral branch in HandleBackfillTick then re-arms the backfill behind it.
            Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.Zero);
        });
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        base.PreStart();
        _shutdownCts = new CancellationTokenSource();

        // Fire an initial sample shortly after start so the first trend point lands
        // without waiting a full SampleInterval, then settle into the periodic cadence.
        Timers.StartPeriodicTimer(
            SampleTimerKey,
            SampleTick.Instance,
            initialDelay: TimeSpan.FromSeconds(5),
            interval: _options.SampleInterval);

        // The purge is idempotent, so it takes a short first tick (min(PurgeInterval, 5min)):
        // scheduling the first sweep a full PurgeInterval out meant a node recycled more often
        // than the interval (e.g. daily restart vs. daily cadence) would never purge
        // (arch-review 04, S9). Fast test cadences (interval < 5min) are unchanged.
        Timers.StartPeriodicTimer(
            PurgeTimerKey,
            PurgeTick.Instance,
            initialDelay: PurgeTimerSchedule.InitialDelay(_options.PurgeInterval),
            interval: _options.PurgeInterval);

        // Fold the trailing hours into the hourly rollup table on a short initial delay
        // (so a fresh active node catches up promptly after a failover), then settle into
        // the periodic cadence. The fold re-processes a lookback window via an idempotent
        // upsert, so a missed tick self-heals on the next fold.
        Timers.StartPeriodicTimer(
            RollupTimerKey,
            RollupTick.Instance,
            initialDelay: TimeSpan.FromSeconds(10),
            interval: _options.RollupInterval);

        // One-shot backfill: fold the whole raw-retention window of existing samples into the
        // hourly rollup table once shortly after start, so long-range (30 d/90 d) trend charts
        // aren't blank until enough wall-clock passes after deploy. The periodic tick only folds
        // the trailing RollupLookbackHours, so without this the deep history stays unrolled. The
        // fold is an idempotent upsert, so re-running on a failover (a fresh singleton re-arms
        // this timer) is safe.
        Timers.StartSingleTimer(
            BackfillTimerKey,
            BackfillTick.Instance,
            timeout: BackfillInitialDelay);
    }

    /// <inheritdoc />
    protected override void PostStop()
    {
        // Cancel before disposing so an in-flight pass observes cancellation; disposing
        // first would race with a pass registering against the token.
        try
        {
            _shutdownCts?.Cancel();
        }
        catch (ObjectDisposedException)
        {
            // Already disposed under a restarted-actor race; nothing to do.
        }

        _shutdownCts?.Dispose();
        _shutdownCts = null;

        base.PostStop();
    }

    /// <summary>
    /// Handles a sample tick. If a sample pass is already in flight the tick is skipped
    /// (logged at debug) so passes never overlap; otherwise the in-flight guard is raised,
    /// the shared <c>capturedAtUtc</c> instant is captured on the actor thread, and the
    /// asynchronous sampling pass is launched off-thread with a <see cref="SampleComplete"/>
    /// piped back to <see cref="Self"/> to lower the guard on the actor thread — so the
    /// mailbox is never blocked while sources are collected and the batch is written.
    /// </summary>
    private void HandleSampleTick()
    {
        if (_sampleInFlight)
        {
            // A prior pass is still awaiting its DB round-trip; coalesce this tick rather than
            // piling a second concurrent pass onto a slow/recovering store.
            _logger.LogDebug("KPI sample tick skipped — a sample pass is already in flight.");
            return;
        }

        _sampleInFlight = true;
        var capturedAt = DateTime.UtcNow;
        var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;

        // RunSamplePass self-isolates its faults (it never throws), but the failure
        // projection is kept as a belt-and-braces guard so even a faulted task still
        // produces a SampleComplete that lowers the in-flight guard — otherwise the loop
        // would wedge permanently.
        RunSamplePass(capturedAt, cancellationToken).PipeTo(
            Self,
            success: () => SampleComplete.Instance,
            failure: ex =>
            {
                _logger.LogError(ex, "KPI sample pass faulted unexpectedly.");
                return SampleComplete.Instance;
            });
    }

    /// <summary>
    /// Runs a single sampling pass: opens a DI scope, enumerates every registered
    /// <see cref="IKpiSampleSource"/>, collects each source's samples (isolating per-source
    /// faults), and bulk-writes the combined batch. The whole body is wrapped so the
    /// returned task never faults — best-effort observability must never disrupt anything.
    /// </summary>
    private async Task RunSamplePass(DateTime capturedAt, CancellationToken cancellationToken)
    {
        try
        {
            await using var scope = _serviceProvider.CreateAsyncScope();
            var sources = scope.ServiceProvider.GetServices<IKpiSampleSource>();
            var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();

            var samples = new List<KpiSample>();
            foreach (var source in sources)
            {
                try
                {
                    var collected = await source.CollectAsync(capturedAt, cancellationToken);
                    samples.AddRange(collected);
                }
                catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
                {
                    // Shutdown interrupted collection; abandon the rest of the pass. The next
                    // active node samples on its own cadence. Not a failure.
                    return;
                }
                catch (Exception ex)
                {
                    // A throwing source must NOT abort the pass or the other sources.
                    _logger.LogError(ex, "KPI source {Source} failed to collect samples.", source.Source);
                }
            }

            if (samples.Count == 0)
            {
                return;
            }

            try
            {
                await repository.RecordSamplesAsync(samples, cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                // Shutdown interrupted the write; the batch is dropped (best-effort). Not a failure.
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "KPI sample write failed for {SampleCount} sample(s).", samples.Count);
            }
        }
        catch (Exception ex)
        {
            // Scope creation or service resolution faulted; swallow and log so the returned
            // task completes normally and the singleton stays alive.
            _logger.LogError(ex, "KPI sample pass failed unexpectedly.");
        }
    }

    /// <summary>
    /// Handles a purge tick: computes both retention cut-offs on the actor thread (raw samples
    /// at <see cref="KpiHistoryOptions.RetentionDays"/>, hourly rollups at the longer
    /// <see cref="KpiHistoryOptions.RollupRetentionDays"/>), then runs both bulk deletes
    /// off-thread and pipes a completion back to <see cref="Self"/>. Purges are daily and
    /// idempotent, so no in-flight guard is needed.
    /// </summary>
    private void HandlePurgeTick()
    {
        var now = DateTime.UtcNow;
        var sampleBefore = now - TimeSpan.FromDays(_options.RetentionDays);
        var rollupBefore = now - TimeSpan.FromDays(_options.RollupRetentionDays);
        var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;

        RunPurgePass(sampleBefore, rollupBefore, cancellationToken).PipeTo(
            Self,
            success: deleted =>
            {
                if (deleted > 0)
                {
                    _logger.LogInformation(
                        "KPI history purge removed {DeletedCount} sample(s) older than {Cutoff:o}.",
                        deleted, sampleBefore);
                }

                return PurgeComplete.Instance;
            },
            failure: ex =>
            {
                _logger.LogError(ex, "KPI history purge faulted unexpectedly.");
                return PurgeComplete.Instance;
            });
    }

    /// <summary>
    /// Runs a single purge sweep: opens a DI scope, resolves the repository, and bulk-deletes
    /// expired raw samples (before <paramref name="sampleBefore"/>) and expired hourly rollups
    /// (before <paramref name="rollupBefore"/>), returning the deleted raw-sample count. The two
    /// deletes are isolated so a failure of one never skips the other, and the whole body is
    /// wrapped so the returned task never faults — on failure the exception is logged and 0 is
    /// returned, mirroring <see cref="RunSamplePass"/>'s best-effort contract.
    /// </summary>
    private async Task<int> RunPurgePass(
        DateTime sampleBefore, DateTime rollupBefore, CancellationToken cancellationToken)
    {
        try
        {
            await using var scope = _serviceProvider.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();

            var deleted = 0;

            // Raw-sample purge — isolated so a fault here does not skip the rollup purge below.
            try
            {
                deleted = await repository.PurgeOlderThanAsync(sampleBefore, cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                // Shutdown interrupted the purge; the next active sweep retries. Not a failure.
                return deleted;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "KPI history raw-sample purge failed unexpectedly.");
            }

            // Rollup purge — retained longer than raw; isolated so its failure does not affect
            // the raw-purge outcome already reported.
            try
            {
                await repository.PurgeRollupsOlderThanAsync(rollupBefore, cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                // Shutdown interrupted the rollup purge; the next active sweep retries. Not a failure.
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "KPI history rollup purge failed unexpectedly.");
            }

            return deleted;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "KPI history purge failed unexpectedly.");
            return 0;
        }
    }

    /// <summary>
    /// Handles a rollup tick. If a fold is already in flight the tick is skipped (logged at
    /// debug) so folds never overlap; otherwise the in-flight guard is raised and the fold is
    /// launched off-thread with a <see cref="RollupComplete"/> piped back to <see cref="Self"/>
    /// to lower the guard on the actor thread. The fold window's upper bound is the current
    /// hour start (exclusive) so the in-progress hour is never folded, and its lower bound is
    /// <see cref="KpiHistoryOptions.RollupLookbackHours"/> earlier so a missed/failover tick
    /// self-heals via the idempotent upsert.
    /// </summary>
    private void HandleRollupTick()
    {
        if (_rollupInFlight || _backfillInFlight)
        {
            // Skip while either a prior periodic fold OR the one-shot full-window backfill is
            // running, so two idempotent upserts never race on overlapping rows. A skipped
            // periodic tick self-heals on the next fold (it re-processes the lookback window).
            _logger.LogDebug("KPI rollup tick skipped — a rollup fold is already in flight.");
            return;
        }

        _rollupInFlight = true;
        var toHourUtc = TruncateToHour(DateTime.UtcNow);
        var fromHourUtc = toHourUtc - TimeSpan.FromHours(_options.RollupLookbackHours);
        var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;

        RunRollupPass(fromHourUtc, toHourUtc, cancellationToken).PipeTo(
            Self,
            success: () => RollupComplete.Instance,
            failure: ex =>
            {
                _logger.LogError(ex, "KPI rollup fold faulted unexpectedly.");
                return RollupComplete.Instance;
            });
    }

    /// <summary>
    /// Runs a single rollup fold: opens a DI scope, resolves the repository, and folds the
    /// complete hours in <c>[<paramref name="fromHourUtc"/>, <paramref name="toHourUtc"/>)</c>
    /// into the hourly rollup table via the idempotent upsert. The whole body is wrapped so the
    /// returned task never faults — best-effort observability must never disrupt anything.
    /// </summary>
    private async Task RunRollupPass(
        DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken)
    {
        try
        {
            await using var scope = _serviceProvider.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
            await repository.FoldHourlyRollupsAsync(fromHourUtc, toHourUtc, cancellationToken);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Shutdown interrupted the fold; the next active node re-folds the lookback window. Not a failure.
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "KPI rollup fold failed unexpectedly.");
        }
    }

    /// <summary>
    /// Handles the one-shot backfill tick. Runs at most once per actor lifetime: if the backfill
    /// has already completed (<see cref="_backfillDone"/>) or a slice is in flight
    /// (<see cref="_backfillInFlight"/>) the tick is ignored. If a periodic fold currently holds
    /// the fold path (<see cref="_rollupInFlight"/>) the backfill defers by re-arming its single
    /// timer, so a backfill slice and the periodic lookback fold never race on overlapping upsert
    /// rows. When no slice plan exists yet it enumerates the raw-retention window
    /// <c>[TruncateToHour(now) − RetentionDays, TruncateToHour(now))</c> into bounded
    /// <see cref="BackfillSliceWidth"/> windows (oldest-first) and self-arms to start draining;
    /// otherwise it dequeues ONE slice, raises the guard, and folds it off the actor thread,
    /// piping a <see cref="BackfillSliceComplete"/> back to release the guard between slices.
    /// </summary>
    private void HandleBackfillTick()
    {
        if (_backfillDone || _backfillInFlight)
        {
            return;
        }

        if (_rollupInFlight)
        {
            // A periodic fold is in flight; defer the next backfill slice briefly so the two
            // idempotent upserts don't contend on overlapping rows. Re-arm the single timer.
            Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.FromSeconds(5));
            return;
        }

        if (_backfillSlices.Count == 0)
        {
            // No slice plan yet: resolve the rollup watermark off the actor thread, then plan on
            // the actor thread (Receive<BackfillPlan>). Raise the guard so periodic ticks defer
            // while planning is in flight; the plan handler lowers it. On a failover restart the
            // watermark lets the plan SKIP or SHRINK to the un-rolled tail instead of re-folding
            // the whole raw-retention window (arch-review 04 round 2, R1).
            _backfillInFlight = true;
            var planCancellationToken = _shutdownCts?.Token ?? CancellationToken.None;

            RunBackfillPlanPass(planCancellationToken).PipeTo(
                Self,
                success: watermark => new BackfillPlan(watermark),
                failure: ex =>
                {
                    _logger.LogError(ex, "KPI rollup backfill plan pass faulted unexpectedly.");
                    return new BackfillPlan(null); // null watermark ⇒ plan the full retention window
                });
            return;
        }

        // Dequeue and fold exactly ONE bounded slice.
        var slice = _backfillSlices.Dequeue();
        _backfillInFlight = true;
        var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;

        RunBackfillPass(slice.FromHourUtc, slice.ToHourUtc, cancellationToken).PipeTo(
            Self,
            success: () => BackfillSliceComplete.Instance,
            failure: ex =>
            {
                _logger.LogError(ex, "KPI rollup backfill slice faulted unexpectedly.");
                return BackfillSliceComplete.Instance;
            });
    }

    /// <summary>
    /// Enumerates <c>[<paramref name="fromHourUtc"/>, <paramref name="toHourUtc"/>)</c> into
    /// contiguous <see cref="BackfillSliceWidth"/> windows (the last possibly shorter),
    /// oldest-first, and enqueues them onto <see cref="_backfillSlices"/>.
    /// </summary>
    private void EnqueueBackfillSlices(DateTime fromHourUtc, DateTime toHourUtc)
    {
        var sliceStart = fromHourUtc;
        while (sliceStart < toHourUtc)
        {
            var sliceEnd = sliceStart + BackfillSliceWidth;
            if (sliceEnd > toHourUtc)
            {
                sliceEnd = toHourUtc;
            }

            _backfillSlices.Enqueue((sliceStart, sliceEnd));
            sliceStart = sliceEnd;
        }
    }

    /// <summary>
    /// Plans the backfill from the resolved rollup watermark (actor thread). The window floor is
    /// the newer of the watermark (re-folding the watermark hour itself is a cheap idempotent
    /// safety margin) and the raw-retention floor; a null watermark (fresh install) keeps the full
    /// retention window. If the floor is already within
    /// <see cref="KpiHistoryOptions.RollupLookbackHours"/> of now the periodic fold's lookback
    /// covers everything from there forward, so the whole historical pass is skipped (the failover
    /// fast-path); otherwise the tail <c>[floor, TruncateToHour(now))</c> is enumerated into
    /// bounded slices and draining is self-armed. Either way the plan-pass guard is lowered here
    /// (arch-review 04 round 2, R1).
    /// </summary>
    private void HandleBackfillPlan(BackfillPlan plan)
    {
        var toHourUtc = TruncateToHour(DateTime.UtcNow);
        var retentionFloor = toHourUtc - TimeSpan.FromDays(_options.RetentionDays);
        // Shrink to the un-rolled tail; re-fold the watermark hour itself (idempotent) as a safety
        // margin. A null watermark (fresh install) keeps the full retention window.
        var floor = plan.Watermark is { } w && w > retentionFloor ? w : retentionFloor;

        if (floor >= toHourUtc - TimeSpan.FromHours(_options.RollupLookbackHours))
        {
            // Failover fast-path: rollups are already current — the periodic fold's lookback
            // window covers everything from the watermark forward, so the full historical pass is
            // skipped entirely (arch-review 04 round 2, R1).
            _backfillInFlight = false;
            _backfillDone = true;
            _logger.LogInformation(
                "KPI rollup backfill skipped — rollups current through {Watermark:o}.", plan.Watermark);
            return;
        }

        EnqueueBackfillSlices(floor, toHourUtc);
        _backfillInFlight = false;

        if (_backfillSlices.Count == 0)
        {
            // Degenerate window (no whole hours to fold) — nothing to backfill.
            _backfillDone = true;
            return;
        }

        _logger.LogInformation(
            "KPI rollup backfill starting — folding [{FromHour:o}, {ToHour:o}) across {SliceCount} "
            + "slice(s) of up to {SliceHours} h (watermark {Watermark:o}).",
            floor, toHourUtc, _backfillSlices.Count, BackfillSliceWidth.TotalHours, plan.Watermark);

        // Start draining via the timer (uniform path with the between-slice re-arm).
        Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.Zero);
    }

    /// <summary>
    /// Resolves the newest folded rollup hour (the backfill watermark) off the actor thread in a
    /// fresh DI scope. Never faults — on cancellation or error it returns <c>null</c>, which the
    /// plan treats as "no rollups", keeping the full retention window (best-effort, mirrors the
    /// other passes).
    /// </summary>
    private async Task<DateTime?> RunBackfillPlanPass(CancellationToken cancellationToken)
    {
        try
        {
            await using var scope = _serviceProvider.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
            return await repository.GetLatestRollupHourAsync(cancellationToken);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Shutdown interrupted the lookup; a fresh active node re-plans. Not a failure.
            return null;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "KPI rollup backfill watermark lookup failed; planning the full retention window.");
            return null;
        }
    }

    /// <summary>
    /// Runs one backfill fold slice: opens a DI scope, resolves the repository, and folds the
    /// complete hours in <c>[<paramref name="fromHourUtc"/>, <paramref name="toHourUtc"/>)</c> into
    /// the hourly rollup table via the same idempotent upsert the periodic fold uses. The whole
    /// body is wrapped so the returned task never faults — best-effort observability must never
    /// disrupt startup. The fold method returns no count, so each slice is logged at Debug with its
    /// window and elapsed wall time (the queue-drained completion is logged at Information).
    /// </summary>
    private async Task RunBackfillPass(
        DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken)
    {
        var startedAt = DateTime.UtcNow;
        try
        {
            await using var scope = _serviceProvider.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
            await repository.FoldHourlyRollupsAsync(fromHourUtc, toHourUtc, cancellationToken);

            _logger.LogDebug(
                "KPI rollup backfill slice folded [{FromHour:o}, {ToHour:o}) in {ElapsedMs:F0} ms.",
                fromHourUtc, toHourUtc, (DateTime.UtcNow - startedAt).TotalMilliseconds);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Shutdown interrupted the backfill; a failover re-runs it (idempotent). Not a failure.
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "KPI rollup backfill failed unexpectedly.");
        }
    }

    /// <summary>
    /// Truncates a UTC instant to the start of its hour (minutes/seconds/ticks dropped),
    /// preserving <see cref="DateTimeKind.Utc"/>. Used to derive the fold window's whole-hour
    /// boundaries so the in-progress hour is excluded.
    /// </summary>
    private static DateTime TruncateToHour(DateTime utc) =>
        new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);

    /// <summary>Self-tick triggering a sampling pass across all registered sources.</summary>
    internal sealed class SampleTick
    {
        public static readonly SampleTick Instance = new();
        private SampleTick() { }
    }

    /// <summary>
    /// Piped-back completion of a sampling pass; lets the pass run off the actor thread and
    /// lowers the <c>_sampleInFlight</c> guard on the actor thread (fires on success and fault).
    /// </summary>
    internal sealed class SampleComplete
    {
        public static readonly SampleComplete Instance = new();
        private SampleComplete() { }
    }

    /// <summary>Self-tick triggering a retention purge sweep.</summary>
    internal sealed class PurgeTick
    {
        public static readonly PurgeTick Instance = new();
        private PurgeTick() { }
    }

    /// <summary>Piped-back completion of a purge sweep; lets the sweep run off the actor thread.</summary>
    internal sealed class PurgeComplete
    {
        public static readonly PurgeComplete Instance = new();
        private PurgeComplete() { }
    }

    /// <summary>Self-tick triggering an hourly rollup fold over the trailing lookback window.</summary>
    internal sealed class RollupTick
    {
        public static readonly RollupTick Instance = new();
        private RollupTick() { }
    }

    /// <summary>
    /// Piped-back completion of a rollup fold; lets the fold run off the actor thread and
    /// lowers the <c>_rollupInFlight</c> guard on the actor thread (fires on success and fault).
    /// </summary>
    internal sealed class RollupComplete
    {
        public static readonly RollupComplete Instance = new();
        private RollupComplete() { }
    }

    /// <summary>
    /// Self-tick triggering the one-shot backfill: on the first tick it enumerates the full
    /// raw-retention window into bounded slices, and each subsequent tick folds one slice. Armed
    /// once via a single timer in <see cref="PreStart"/> (and re-armed to drain the next slice or
    /// to defer past an in-flight periodic fold); the whole pass runs at most once per actor
    /// lifetime.
    /// </summary>
    internal sealed class BackfillTick
    {
        public static readonly BackfillTick Instance = new();
        private BackfillTick() { }
    }

    /// <summary>
    /// Piped-back result of the backfill plan pass: the newest folded rollup hour (the watermark),
    /// or <c>null</c> when no rollups exist. Consumed on the actor thread to skip or shrink the
    /// backfill to the un-rolled tail (arch-review 04 round 2, R1).
    /// </summary>
    internal sealed record BackfillPlan(DateTime? Watermark);

    /// <summary>
    /// Piped-back completion of ONE backfill fold slice; lets the slice run off the actor thread
    /// and, on the actor thread, lowers the <c>_backfillInFlight</c> guard BETWEEN slices — then
    /// either drains the next slice (self-armed timer) or, when the queue is empty, latches
    /// <c>_backfillDone</c> (fires on success and fault).
    /// </summary>
    internal sealed class BackfillSliceComplete
    {
        public static readonly BackfillSliceComplete Instance = new();
        private BackfillSliceComplete() { }
    }
}
