using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;

namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit;

/// <summary>
/// Central singleton for Site Call Audit. Receives
/// <see cref="UpsertSiteCallCommand"/> messages and persists each
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall"/> row via
/// <see cref="ISiteCallAuditRepository.UpsertAsync"/> — idempotent monotonic
/// upsert. Out-of-order or duplicate updates are silent no-ops at the
/// repository layer; the actor always replies <see cref="UpsertSiteCallReply"/>
/// with <c>Accepted=true</c> in that case because storage state is consistent
/// and the site is free to consider its packet acked.
/// </summary>
/// <remarks>
/// <para>
/// Implemented: direct <see cref="UpsertSiteCallCommand"/> telemetry ingest,
/// query, detail and KPI handlers, the central→site Retry/Discard
/// relay (the relay handlers live in this actor), the periodic
/// per-site reconciliation puller that backfills lost telemetry (Piece A —
/// <see cref="OnReconciliationTickAsync"/>, the documented self-heal pull), and
/// the daily terminal-row purge scheduler (Piece B —
/// <see cref="OnPurgeTickAsync"/>, which invokes
/// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/> on a timer). Both
/// background timers are started in <see cref="PreStart"/>, but on independent
/// preconditions. The purge timer is armed whenever
/// background timers are enabled (it needs only the repository, which every
/// production / reconciliation ctor always has) — it is NOT gated on the
/// reconciliation collaborators, so a host that registers Site Call Audit
/// without the reconciliation client still purges and the central
/// <c>SiteCalls</c> table cannot grow unbounded. The reconciliation timer
/// additionally requires its collaborators (<see cref="IPullSiteCallsClient"/> +
/// <see cref="ISiteEnumerator"/>) and logs a Warning when it cannot arm. The
/// repo-only MSSQL test ctor disables both timers (background timers off) so the
/// read/upsert tests see no scheduled side effects.
/// </para>
/// <para>
/// Per CLAUDE.md "audit-write failure NEVER aborts the user-facing action" —
/// the actor catches every exception from the repository call and replies
/// <c>Accepted=false</c> without rethrowing, so the central singleton stays
/// alive. The <see cref="SupervisorStrategy"/> override governs the actor's
/// <em>children</em>, not the actor itself; this actor has no children today,
/// so the override is currently inert. It returns a one-for-one strategy with
/// <see cref="Akka.Actor.SupervisorStrategy.DefaultDecider"/> (Restart on most
/// exceptions, Stop on <see cref="ActorInitializationException"/> /
/// <see cref="ActorKilledException"/>) and <c>maxNrOfRetries: 0</c>, so any
/// future child that throws is Stopped on the first failure — a deliberate
/// "fail loudly" posture for the central singleton's eventual sub-actors
/// (reconciliation puller, purge scheduler). Self-supervision of this actor
/// is whatever the parent <see cref="Akka.Cluster.Tools.Singleton.ClusterSingletonManager"/>
/// supplies; the in-handler <c>try/catch</c> in <see cref="OnUpsertAsync"/>
/// is what actually keeps the singleton alive across repository faults.
/// </para>
/// <para>
/// Two constructors exist for the same reason as
/// <c>AuditLogIngestActor</c>: production wiring resolves the
/// scoped EF repository from a fresh DI scope per message because the actor
/// is a long-lived cluster singleton, while tests inject a concrete
/// <see cref="ISiteCallAuditRepository"/> against a per-test MSSQL fixture
/// so the actor exercises the real monotonic upsert SQL end to end.
/// </para>
/// </remarks>
public class SiteCallAuditActor : ReceiveActor
{
    /// <summary>Maximum page size honoured by a <see cref="SiteCallQueryRequest"/>.</summary>
    private const int MaxPageSize = 200;

    /// <summary>
    /// Hard ceiling on the number of <c>PullSiteCalls</c> RPCs
    /// issued for a single site within ONE reconciliation tick when the site keeps
    /// reporting <see cref="PullSiteCallsResponse.MoreAvailable"/>. Bounds the
    /// within-tick continuation drain so a misbehaving site (or a pathological
    /// single-timestamp saturation that pins the cursor) can never spin the
    /// dispatcher unbounded; the remaining backlog drains on the next tick.
    /// </summary>
    private const int MaxReconciliationPagesPerTick = 50;

    private readonly IServiceProvider? _serviceProvider;
    private readonly ISiteCallAuditRepository? _injectedRepository;
    private readonly SiteCallAuditOptions _options;
    private readonly ILogger<SiteCallAuditActor> _logger;

    /// <summary>
    /// Central direct-write audit sink for operator Retry/Discard relay actions.
    /// Null when no writer is registered (minimal hosts / most unit
    /// tests) — in that case the relay emits no audit row and never reads the
    /// <c>SiteCalls</c> mirror, preserving the "central touches no mirror row on
    /// the relay path" behaviour. When present, a successful relay emits ONE
    /// best-effort <c>CachedResolve</c> row recording the operator identity; the
    /// site remains the source of truth for the state change itself.
    /// </summary>
    private readonly ICentralAuditWriter? _auditWriter;

    /// <summary>
    /// Reconciliation collaborators (Piece A). The per-site self-heal pull
    /// (<see cref="IPullSiteCallsClient"/>) and the site list
    /// (<see cref="ISiteEnumerator"/>). On the production path these are
    /// resolved once from the root <see cref="IServiceProvider"/> (central
    /// singletons registered by <c>AddAuditLogCentralReconciliationClient</c>);
    /// in the test path they are injected directly. They are <c>null</c> when
    /// the actor was built via the repo-only test ctor — in that case the
    /// reconciliation tick is NOT started (see <see cref="StartReconciliationTimer"/>).
    /// The purge tick, by contrast, does NOT depend on these collaborators:
    /// it needs only the repository, so it is armed by
    /// <see cref="_backgroundTimersEnabled"/> alone (see <see cref="StartPurgeTimer"/>).
    /// </summary>
    private readonly IPullSiteCallsClient? _pullClient;
    private readonly ISiteEnumerator? _siteEnumerator;

    /// <summary>
    /// Master switch for the two background schedulers (reconciliation + purge),
    /// set <c>true</c> by the production and reconciliation ctors and <c>false</c>
    /// by the repo-only MSSQL test ctor. The purge timer is
    /// gated on THIS flag rather than on the reconciliation collaborators, so a
    /// host that omits the reconciliation client still purges (no unbounded
    /// central <c>SiteCalls</c> growth) while the MSSQL read/upsert tests stay
    /// free of any scheduled side effects.
    /// </summary>
    private readonly bool _backgroundTimersEnabled;

    /// <summary>
    /// Per-site reconciliation watermark — the composite
    /// <c>(UpdatedAtUtc, TrackedOperationId)</c> keyset of the highest row seen
    /// for that site on a previous tick. The next tick asks for rows
    /// strictly greater than this pair, so a burst sharing one exact
    /// <see cref="SiteCall.UpdatedAtUtc"/> drains via the id tiebreak rather than
    /// pinning the timestamp forever; idempotent monotonic
    /// <see cref="ISiteCallAuditRepository.UpsertAsync"/> still swallows any
    /// boundary duplicate. In-memory for the singleton's lifetime — a
    /// failover / restart resets every cursor to
    /// <c>(DateTime.MinValue, null)</c>, which is conservative but correct (the
    /// next tick re-pulls from the start and idempotent upsert dedupes). Mirrors
    /// <c>SiteAuditReconciliationActor</c>'s cursor (now composite).
    /// </summary>
    private readonly Dictionary<string, (DateTime Since, string? AfterId)> _reconciliationCursors = new();

    /// <summary>
    /// Per-site "pinned" latch — <c>true</c> once a site's composite
    /// cursor stopped advancing while the site still reported
    /// <c>MoreAvailable=true</c> (in practice only a legacy site that ignores the
    /// <c>after_id</c> keyset field). Tracks the latch so only <em>transitions</em>
    /// publish <see cref="SiteCallReconciliationPinnedChanged"/> on the EventStream
    /// (pinned on latch, unpinned when progress resumes), mirroring the sibling
    /// <c>SiteAuditReconciliationActor</c>'s stalled-state machine — no flood of
    /// redundant notifications.
    /// </summary>
    private readonly Dictionary<string, bool> _reconciliationPinned = new();

    /// <summary>
    /// Actor-system EventStream captured on the actor thread at handler-registration
    /// time. The reconciliation pass runs off-mailbox and publishes the pinned-state
    /// transition from there, so it must not reach through <c>Context</c>.
    /// </summary>
    private Akka.Event.EventStream _eventStream = null!;

    /// <summary>
    /// Single-flight guard for the off-mailbox reconciliation pass. Raised on the
    /// actor thread when a tick launches a pass and lowered on the actor thread
    /// when the piped <see cref="ReconciliationComplete"/> arrives, so the
    /// per-site cursor/pinned dictionaries the pass mutates are only ever touched
    /// by one task at a time and the mailbox supplies the memory barrier between
    /// consecutive passes.
    /// </summary>
    private bool _reconciling;

    /// <summary>Single-flight guard for the off-mailbox terminal-row purge pass.</summary>
    private bool _purging;

    private ICancelable? _reconciliationTimer;
    private ICancelable? _purgeTimer;

    /// <summary>
    /// The central→site command transport — the
    /// <c>CentralCommunicationActor</c>, which owns the per-site
    /// transport map and routes a <see cref="SiteEnvelope"/> to the
    /// owning site. Set via <see cref="RegisterCentralCommunication"/> by the
    /// Host after both actors exist (this actor is a cluster singleton; the
    /// transport actor is created separately). Null until registration
    /// completes — a relay arriving before then is answered with a
    /// <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome, because there
    /// is genuinely no route to any site yet.
    /// </summary>
    private IActorRef? _centralCommunication;

    /// <summary>
    /// Test-mode constructor — injects a concrete repository instance whose
    /// lifetime exceeds the test, so the actor reuses the same instance
    /// across every message. Used by the MSSQL-backed TestKit fixture.
    /// An optional <paramref name="options"/> lets a test pin the stuck/KPI
    /// windows; when omitted the production defaults apply.
    /// <para>
    /// This ctor disables BOTH background timers (sets
    /// <see cref="_backgroundTimersEnabled"/> to <c>false</c>) and injects no
    /// reconciliation client/enumerator, so neither the reconciliation tick nor
    /// the purge tick fires — the MSSQL-backed read/upsert tests must see no
    /// scheduled side effects (no phantom pulls, no background purge).
    /// </para>
    /// </summary>
    /// <param name="repository">Concrete repository instance to use for all messages.</param>
    /// <param name="logger">Logger for diagnostics and error reporting.</param>
    /// <param name="options">Optional configuration overrides; production defaults apply when null.</param>
    /// <param name="auditWriter">Optional central direct-write audit sink for operator relay actions; null disables the relay audit row.</param>
    public SiteCallAuditActor(
        ISiteCallAuditRepository repository,
        ILogger<SiteCallAuditActor> logger,
        SiteCallAuditOptions? options = null,
        ICentralAuditWriter? auditWriter = null)
    {
        ArgumentNullException.ThrowIfNull(repository);
        ArgumentNullException.ThrowIfNull(logger);

        _injectedRepository = new SerializedRepository(repository);
        _logger = logger;
        _options = options ?? new SiteCallAuditOptions();
        _auditWriter = auditWriter;

        // Repo-only MSSQL test ctor: keep BOTH background timers off so the
        // read/upsert tests see no scheduled side effects.
        _backgroundTimersEnabled = false;

        RegisterHandlers();
    }

    /// <summary>
    /// Test-mode constructor for the reconciliation tick (Piece A) — injects a
    /// concrete repository PLUS the two reconciliation collaborators directly,
    /// so the per-site self-heal pull is unit-testable in-memory without a DI
    /// container or a live gRPC channel. Background timers are enabled, so the
    /// purge tick starts (it needs only the repository) and the reconciliation
    /// tick starts too because the client + enumerator are present — see
    /// <see cref="StartReconciliationTimer"/> / <see cref="StartPurgeTimer"/>.
    /// </summary>
    /// <param name="repository">Concrete repository instance used for upserts and purges.</param>
    /// <param name="siteEnumerator">Enumerates the sites to reconcile each tick.</param>
    /// <param name="pullClient">Pull client used to fetch changed rows from each site.</param>
    /// <param name="logger">Logger for diagnostics and error reporting.</param>
    /// <param name="options">Optional configuration overrides; production defaults apply when null.</param>
    /// <remarks>
    /// Public (not internal) because Akka's default <c>ActivatorProducer</c>
    /// instantiates the actor via reflection with public-only binding flags —
    /// an internal ctor yields a <c>MissingMethodException</c> at actor
    /// creation. Distinguished from the production <see cref="IServiceProvider"/>
    /// ctor by its concrete-collaborator parameter list; only the test project
    /// (or a host that hand-resolves the collaborators) constructs it this way.
    /// </remarks>
    public SiteCallAuditActor(
        ISiteCallAuditRepository repository,
        ISiteEnumerator siteEnumerator,
        IPullSiteCallsClient pullClient,
        ILogger<SiteCallAuditActor> logger,
        SiteCallAuditOptions? options = null)
    {
        ArgumentNullException.ThrowIfNull(repository);
        ArgumentNullException.ThrowIfNull(siteEnumerator);
        ArgumentNullException.ThrowIfNull(pullClient);
        ArgumentNullException.ThrowIfNull(logger);

        _injectedRepository = new SerializedRepository(repository);
        _siteEnumerator = siteEnumerator;
        _pullClient = pullClient;
        _logger = logger;
        _options = options ?? new SiteCallAuditOptions();

        // Reconciliation test ctor: collaborators present, so both timers arm
        // (the reconciliation tick uses the collaborators; the purge tick needs
        // only the repository).
        _backgroundTimersEnabled = true;

        RegisterHandlers();
    }

    /// <summary>
    /// Production constructor — resolves <see cref="ISiteCallAuditRepository"/>
    /// from a fresh DI scope per message because the repository is a scoped EF
    /// Core service registered by <c>AddConfigurationDatabase</c>. The actor
    /// itself is a long-lived cluster singleton, so it cannot hold a scope
    /// across messages.
    /// </summary>
    /// <param name="serviceProvider">DI service provider used to create a scoped repository per message.</param>
    /// <param name="options">Actor configuration (stuck threshold, KPI interval, relay timeout).</param>
    /// <param name="logger">Logger for diagnostics and error reporting.</param>
    public SiteCallAuditActor(
        IServiceProvider serviceProvider,
        SiteCallAuditOptions options,
        ILogger<SiteCallAuditActor> logger)
    {
        ArgumentNullException.ThrowIfNull(serviceProvider);
        ArgumentNullException.ThrowIfNull(options);
        ArgumentNullException.ThrowIfNull(logger);

        _serviceProvider = serviceProvider;
        _options = options;
        _logger = logger;

        // Central direct-write audit sink for operator relay actions.
        // Resolved once from the root provider — ICentralAuditWriter is a central
        // singleton (registered by AuditLog). Null-safe: a host that omits it just
        // relays without emitting the operator-identity row.
        _auditWriter = serviceProvider.GetService<ICentralAuditWriter>();

        // Reconciliation collaborators (Piece A) are central-only singletons
        // registered by AddAuditLogCentralReconciliationClient — always on the
        // central composition root (Program.cs). Resolve them once here (the
        // actor itself is a long-lived singleton; the repository is the only
        // scoped service and is still resolved per-tick/per-message). GetService
        // (not GetRequiredService) so a host that somehow omits the helper
        // degrades to "no reconciliation tick" rather than a startup crash —
        // the tick startup gates on both being non-null.
        _pullClient = serviceProvider.GetService<IPullSiteCallsClient>();
        _siteEnumerator = serviceProvider.GetService<ISiteEnumerator>();

        // Production path: background timers run. The purge tick is armed
        // unconditionally here (it needs only the repository); the reconciliation
        // tick additionally requires its collaborators and logs a Warning if
        // they were not registered.
        _backgroundTimersEnabled = true;

        RegisterHandlers();
    }

    /// <summary>
    /// Wires up the message handlers shared by both constructors: the
    /// ingest path plus the read-side (query, detail, global + per-site
    /// KPI). All read handlers reply to an Ask, so they capture <c>Sender</c>
    /// before the first await and <c>PipeTo</c> the result back.
    /// </summary>
    private void RegisterHandlers()
    {
        ReceiveAsync<UpsertSiteCallCommand>(OnUpsertAsync);
        Receive<SiteCallQueryRequest>(HandleQuery);
        Receive<SiteCallDetailRequest>(HandleDetail);
        Receive<SiteCallKpiRequest>(HandleKpi);
        Receive<PerSiteSiteCallKpiRequest>(HandlePerSiteKpi);
        Receive<PerNodeSiteCallKpiRequest>(HandlePerNodeKpi);

        // Central→site Retry/Discard relay for parked cached calls.
        Receive<RegisterCentralCommunication>(msg =>
        {
            _centralCommunication = msg.CentralCommunication;
            _logger.LogInformation("SiteCallAudit registered central→site communication transport");
        });
        Receive<RetrySiteCallRequest>(HandleRetrySiteCall);
        Receive<DiscardSiteCallRequest>(HandleDiscardSiteCall);

        // Piece A/B: self-ticks for the periodic reconciliation pull and
        // the daily terminal-row purge. Handlers stay alive across faults via
        // their own per-site / per-tick try/catch (mirroring the ingest path);
        // the timers are only started when their collaborators are available.
        //
        // OFF-MAILBOX (WP2.2). Both passes run as PipeTo-completed background
        // tasks behind a single-flight guard rather than as ReceiveAsync bodies.
        // A ReceiveAsync handler occupies the actor for its whole duration, and a
        // reconciliation pass is unbounded work — every site, up to
        // MaxReconciliationPagesPerTick network pulls each, one upsert per row.
        // Post-outage catch-up therefore blocked telemetry ingest, UI queries and
        // KPI Asks behind it until the drain finished, and those callers timed out
        // rather than queued. NotificationOutboxActor's dispatch sweep is the
        // in-repo reference for this shape.
        Receive<ReconciliationTick>(_ => HandleReconciliationTick());
        Receive<ReconciliationComplete>(_ => _reconciling = false);
        Receive<PurgeTick>(_ => HandlePurgeTick());
        Receive<PurgeComplete>(_ => _purging = false);

        // Captured on the actor thread so the background passes never touch
        // Context off-thread. EventStream itself is thread-safe.
        _eventStream = Context.System.EventStream;
    }

    /// <summary>
    /// Launches a reconciliation pass unless one is already in flight, dropping
    /// the tick if so. Overlapping passes are not merely wasteful — they would
    /// race on the per-site cursor and pinned-latch dictionaries, which the
    /// single-flight guard keeps confined to one task at a time (the guard itself
    /// is only ever mutated on the actor thread: raised here, lowered by the
    /// piped completion message).
    /// </summary>
    private void HandleReconciliationTick()
    {
        if (_reconciling)
        {
            return;
        }

        _reconciling = true;

        // OnReconciliationTickAsync swallows its own per-site errors, but the
        // failure projection is kept as a belt-and-braces guard so even a faulted
        // task still lowers the guard — otherwise reconciliation would wedge
        // permanently after a single unexpected throw.
        OnReconciliationTickAsync().PipeTo(
            Self,
            success: () => ReconciliationComplete.Instance,
            failure: ex =>
            {
                _logger.LogError(ex, "SiteCallAudit reconciliation pass faulted unexpectedly.");
                return ReconciliationComplete.Instance;
            });
    }

    /// <summary>
    /// Launches a purge pass unless one is already in flight. Same single-flight
    /// discipline as <see cref="HandleReconciliationTick"/>: a purge that outlives
    /// its interval (a large catch-up after an outage) must not stack.
    /// </summary>
    private void HandlePurgeTick()
    {
        if (_purging)
        {
            return;
        }

        _purging = true;

        OnPurgeTickAsync().PipeTo(
            Self,
            success: () => PurgeComplete.Instance,
            failure: ex =>
            {
                _logger.LogError(ex, "SiteCallAudit purge pass faulted unexpectedly.");
                return PurgeComplete.Instance;
            });
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        base.PreStart();
        StartReconciliationTimer();
        StartPurgeTimer();
    }

    /// <inheritdoc />
    protected override void PostStop()
    {
        _reconciliationTimer?.Cancel();
        _purgeTimer?.Cancel();
        base.PostStop();
    }

    /// <summary>
    /// Starts the periodic reconciliation tick — but ONLY when background timers
    /// are enabled AND both the pull client and the site enumerator are
    /// available. The repo-only test ctor disables background timers, so the tick
    /// is gated off there (the MSSQL read/upsert tests must not fire phantom
    /// pulls); the reconciliation test ctor and the production ctor (which
    /// resolves both from the SP) start it. When background
    /// timers are enabled but the collaborators were not registered, log a
    /// Warning so a misconfigured host surfaces the missing self-heal rather than
    /// silently skipping it.
    /// </summary>
    private void StartReconciliationTimer()
    {
        if (!_backgroundTimersEnabled)
        {
            return;
        }

        if (_pullClient is null || _siteEnumerator is null)
        {
            _logger.LogWarning(
                "SiteCallAudit reconciliation timer not started — the reconciliation "
                + "collaborators (IPullSiteCallsClient / ISiteEnumerator) were not registered; "
                + "lost cached-call telemetry will not self-heal until they are wired up. "
                + "The daily terminal-row purge still runs.");
            return;
        }

        var interval = _options.ResolvedReconciliationInterval;
        _reconciliationTimer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(
            initialDelay: interval,
            interval: interval,
            receiver: Self,
            message: ReconciliationTick.Instance,
            sender: Self);
    }

    /// <summary>
    /// Starts the daily purge tick. The purge needs ONLY the
    /// repository — never the reconciliation collaborators — so it is gated on
    /// <see cref="_backgroundTimersEnabled"/> alone, NOT on
    /// <see cref="_pullClient"/> / <see cref="_siteEnumerator"/>. This decouples
    /// the daily terminal-row purge from the reconciliation client: a host that
    /// registers Site Call Audit without the reconciliation client still purges,
    /// so the central <c>SiteCalls</c> table can never grow unbounded just
    /// because the self-heal puller is absent. Only the repo-only MSSQL test ctor
    /// (background timers off) skips it, keeping the read/upsert tests free of
    /// scheduled side effects.
    /// </summary>
    private void StartPurgeTimer()
    {
        if (!_backgroundTimersEnabled)
        {
            return;
        }

        var interval = _options.ResolvedPurgeInterval;
        _purgeTimer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(
            // Short first tick (min(interval, 5min)): purges are idempotent, and a full-interval
            // initial delay meant a daily-recycled node never purged (arch-review 04, S9). Fast
            // test cadences (interval < 5min) are unchanged.
            initialDelay: PurgeTimerSchedule.InitialDelay(interval),
            interval: interval,
            receiver: Self,
            message: PurgeTick.Instance,
            sender: Self);
    }

    /// <inheritdoc />
    protected override SupervisorStrategy SupervisorStrategy()
    {
        return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
            Akka.Actor.SupervisorStrategy.DefaultDecider);
    }

    private async Task OnUpsertAsync(UpsertSiteCallCommand cmd)
    {
        // Sender is captured before the first await — Akka resets Sender
        // between message dispatches, so a post-await Tell would go to
        // DeadLetters.
        var replyTo = Sender;
        var id = cmd.SiteCall.TrackedOperationId;

        // Scope-per-message mirrors AuditLogIngestActor — production EF
        // repository is scoped; the injected-repository mode (tests) skips
        // the scope entirely.
        IServiceScope? scope = null;
        ISiteCallAuditRepository repository;
        if (_injectedRepository is not null)
        {
            repository = _injectedRepository;
        }
        else
        {
            scope = _serviceProvider!.CreateScope();
            repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
        }

        try
        {
            // Stamp IngestedAtUtc at central-side persist
            // time on every upsert, mirroring AuditLogIngestActor's combined-
            // telemetry hot path. IngestedAtUtc is the "central ingested (or
            // last refreshed) this row" timestamp; callers (telemetry,
            // future reconciliation puller, direct-writes) cannot in general
            // know they are running on central, so the actor owns the stamp.
            var siteCall = cmd.SiteCall with { IngestedAtUtc = DateTime.UtcNow };
            await repository.UpsertAsync(siteCall).ConfigureAwait(false);
            replyTo.Tell(new UpsertSiteCallReply(id, Accepted: true));
        }
        catch (Exception ex)
        {
            // Per CLAUDE.md: audit-write failure NEVER aborts the user-facing
            // action — log and reply Accepted=false; do NOT rethrow (the
            // central singleton MUST stay alive).
            _logger.LogError(ex, "SiteCallAudit upsert failed for {TrackedOperationId}", id);
            replyTo.Tell(new UpsertSiteCallReply(id, Accepted: false));
        }
        finally
        {
            scope?.Dispose();
        }
    }

    // ── Piece A: periodic per-site reconciliation pull (self-heal) ──

    /// <summary>
    /// One reconciliation pass: enumerate every known site and, per site, pull
    /// changed <see cref="SiteCall"/> rows since that site's cursor and upsert
    /// them idempotently — the documented self-heal when best-effort gRPC push
    /// telemetry is lost. This is a mirror, NOT a dispatcher: cached-call
    /// delivery stays site-local; upserting reconciled rows only refreshes the
    /// eventually-consistent central <c>SiteCalls</c> mirror.
    /// </summary>
    /// <remarks>
    /// Mirrors <c>SiteAuditReconciliationActor</c>'s structure (per-site cursor,
    /// per-site try/catch failure isolation, advance the cursor by the max
    /// observed <see cref="SiteCall.UpdatedAtUtc"/>) but is deliberately simpler:
    /// no stalled-detection EventStream machinery — just cursor + pull + upsert
    /// + advance. One DI scope per tick is opened and the same repository reused
    /// across every site in that tick.
    /// </remarks>
    private async Task OnReconciliationTickAsync()
    {
        // The collaborators are guaranteed non-null: the tick is only scheduled
        // when both are present (StartReconciliationTimer). Assert via the
        // local copies so a future refactor that drops the gate fails loudly.
        var enumerator = _siteEnumerator!;
        var client = _pullClient!;

        IReadOnlyList<SiteEntry> sites;
        try
        {
            // No ambient CancellationToken in a ReceiveActor handler — None is
            // intentional; the work is bounded by the reconciliation interval
            // plus the singleton's graceful-stop drain on PhaseClusterLeave.
            sites = await enumerator.EnumerateAsync().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "SiteCallAudit site enumeration failed; skipping reconciliation tick.");
            return;
        }

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

        // Open the scope INLINE with CreateAsyncScope + await using
        // so the scoped EF Core repository (an IAsyncDisposable DbContext) disposes
        // asynchronously at end of tick rather than blocking the Akka dispatcher
        // thread on a synchronous Dispose() of pending connection cleanup — the tick
        // holds the scope across many awaited UpsertAsync calls. Mirrors the sibling
        // SiteAuditReconciliationActor.OnTickAsync. ResolveRepository() (sync Dispose)
        // is retained for the synchronous message-handler paths. In the injected-
        // repository test path there is no scope to open and the test repo is reused.
        if (_injectedRepository is not null)
        {
            await ReconcileSitesAsync(sites, client, _injectedRepository).ConfigureAwait(false);
            return;
        }

        await using var scope = _serviceProvider!.CreateAsyncScope();
        var repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
        await ReconcileSitesAsync(sites, client, repository).ConfigureAwait(false);
    }

    /// <summary>
    /// Reconciles every site in the tick against a single resolved repository,
    /// isolating per-site faults so one bad site never sinks the rest of the
    /// pass (the failing site's cursor is left at its previous value so the next
    /// tick retries the same window).
    /// </summary>
    private async Task ReconcileSitesAsync(
        IReadOnlyList<SiteEntry> sites, IPullSiteCallsClient client, ISiteCallAuditRepository repository)
    {
        foreach (var site in sites)
        {
            try
            {
                await ReconcileSiteAsync(site, client, repository).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                // Failure-isolation invariant: one site's fault (transport,
                // repository write) must NOT sink the rest of the tick. The
                // failing site's cursor is left at its previous value so the
                // next tick retries the same window.
                _logger.LogWarning(
                    ex,
                    "SiteCallAudit reconciliation pull failed for site {SiteId}; other sites continue.",
                    site.SiteId);
            }
        }
    }

    /// <summary>
    /// Issues one <c>PullSiteCalls</c> RPC against the site, upserts the
    /// returned rows idempotently, and advances the site's cursor to the maximum
    /// <see cref="SiteCall.UpdatedAtUtc"/> observed. The pull client returns rows
    /// oldest-first with <c>SourceSite</c> already re-stamped from the dialed
    /// site id, so the actor upserts them verbatim (re-stamping
    /// <c>IngestedAtUtc</c> at central persist time, as the telemetry path does).
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Coarse per-site retry — a deliberate divergence from
    /// <c>SiteAuditReconciliationActor</c>.</b> That sibling tracks
    /// a per-EventId attempt counter and permanently abandons a row after a
    /// threshold so a single un-insertable row cannot block a site's cursor
    /// forever. This actor deliberately does NOT: any throw inside the loop
    /// propagates to <see cref="OnReconciliationTickAsync"/>'s per-site catch,
    /// which leaves the site's cursor at its previous value, so the next tick
    /// re-pulls the whole batch from <c>since</c>. A persistently-bad row therefore
    /// holds the site's cursor and re-pulls the batch every tick. This is
    /// acceptable here because <see cref="ISiteCallAuditRepository.UpsertAsync"/> is
    /// monotonic and idempotent — re-pulling already-ingested rows is a cheap
    /// no-op — and the <c>SiteCalls</c> table is an eventually-consistent mirror,
    /// not the source of truth, so a slow site simply lags rather than corrupts.
    /// </para>
    /// <para>
    /// <b>Composite keyset cursor.</b> The cursor is the
    /// <c>(UpdatedAtUtc, TrackedOperationId)</c> pair of the maximum row seen, and
    /// the pull asks for rows strictly greater than it. The boundary row is NOT
    /// re-pulled (unlike the prior inclusive-timestamp cursor), and — critically —
    /// a burst of more than <see cref="SiteCallAuditOptions.ReconciliationBatchSize"/>
    /// rows sharing one exact <see cref="SiteCall.UpdatedAtUtc"/> now drains via the
    /// id tiebreak instead of pinning the timestamp forever. The idempotent monotonic
    /// upsert still dedupes any overlap. Matches the composite keyset the site's
    /// <c>ReadChangedSinceAsync</c> honours.
    /// </para>
    /// <para>
    /// <b>Consumes <see cref="PullSiteCallsResponse.MoreAvailable"/>
    /// to guarantee forward progress.</b> The method continues pulling within the
    /// same tick while the site reports <c>MoreAvailable=true</c>, bounded by
    /// <see cref="MaxReconciliationPagesPerTick"/> so a misbehaving site can never
    /// spin the dispatcher. Each page advances the in-flight composite cursor. If a
    /// page fails to advance the cursor yet the site still reports more available,
    /// the site is NOT honouring the <c>after_id</c> keyset (a legacy site that
    /// predates the composite keyset): continuing would re-pull the identical window forever, so the loop
    /// latches the site as <em>pinned</em> and publishes
    /// <see cref="SiteCallReconciliationPinnedChanged"/> on the EventStream — the
    /// dead-end becomes a health-observable condition rather than a silent log line.
    /// When a later tick makes progress the latch clears with <c>Pinned=false</c>.
    /// This aligns with <c>SiteAuditReconciliationActor</c>'s
    /// <c>SiteAuditTelemetryStalledChanged</c> transition-only publication.
    /// </para>
    /// </remarks>
    private async Task ReconcileSiteAsync(
        SiteEntry site, IPullSiteCallsClient client, ISiteCallAuditRepository repository)
    {
        var cursor = _reconciliationCursors.TryGetValue(site.SiteId, out var c)
            ? c
            : (Since: DateTime.MinValue, AfterId: (string?)null);

        // Drain within the tick while the site keeps reporting
        // MoreAvailable, bounded by MaxReconciliationPagesPerTick so a misbehaving
        // site can never spin the dispatcher. Each page advances the in-flight
        // composite cursor; a page that fails to advance it while MoreAvailable is
        // still true means the site ignores after_id (legacy) — latch + publish
        // the pinned state and break.
        for (var page = 0; page < MaxReconciliationPagesPerTick; page++)
        {
            var since = cursor.Since;
            var afterId = cursor.AfterId;
            var response = await client
                .PullAsync(site.SiteId, since, afterId, _options.ReconciliationBatchSize, CancellationToken.None)
                .ConfigureAwait(false);

            var maxUpdated = since;
            var maxAfterId = afterId;
            var nowUtc = DateTime.UtcNow;
            foreach (var row in response.SiteCalls)
            {
                // IngestedAtUtc is the "central ingested (or last refreshed) this
                // row" stamp — owned by the central actor, exactly as OnUpsertAsync
                // does for the telemetry path. Monotonic UpsertAsync makes a row
                // already present (from a prior push) a silent no-op.
                var siteCall = row with { IngestedAtUtc = nowUtc };
                await repository.UpsertAsync(siteCall).ConfigureAwait(false);

                // Advance the composite max: greater timestamp wins; on a tie the
                // greater TrackedOperationId (ordinal, matching the site's SQLite
                // BINARY text collation) wins.
                var rowId = row.TrackedOperationId.ToString();
                if (row.UpdatedAtUtc > maxUpdated ||
                    (row.UpdatedAtUtc == maxUpdated && string.CompareOrdinal(rowId, maxAfterId) > 0))
                {
                    maxUpdated = row.UpdatedAtUtc;
                    maxAfterId = rowId;
                }
            }

            // Did the composite cursor move? (timestamp advanced OR — at the same
            // timestamp — the id tiebreak advanced). CompareOrdinal handles nulls
            // (CompareOrdinal(x, null) > 0 for any non-null x).
            var advanced = maxUpdated > since || string.CompareOrdinal(maxAfterId, afterId) != 0;

            // Persist the advanced cursor after every page so a fault on a later
            // page (caught per-site upstream) still keeps the rows already drained.
            cursor = (maxUpdated, maxAfterId);
            _reconciliationCursors[site.SiteId] = cursor;

            // Progress resumed → clear any prior pinned latch (publishes the
            // Pinned=false transition exactly once).
            if (advanced)
            {
                SetReconciliationPinned(site.SiteId, pinned: false);
            }

            if (!response.MoreAvailable)
            {
                // Backlog fully drained for this site this tick.
                return;
            }

            if (!advanced)
            {
                // The composite cursor did not advance yet the site still reports
                // MoreAvailable — the site ignored after_id (a legacy pre-keyset
                // site), so a continuation would re-pull the identical window
                // forever. Latch + publish the pinned state and stop; the next tick
                // re-checks and unpins if the site catches up.
                SetReconciliationPinned(site.SiteId, pinned: true);
                _logger.LogWarning(
                    "SiteCallAudit reconciliation for site {SiteId} is pinned: the composite "
                    + "(UpdatedAtUtc, TrackedOperationId) cursor did not advance past ({CursorUtc:o}, {AfterId}) "
                    + "yet the site reports more rows available — the site does not honour the after_id keyset "
                    + "(legacy). The backlog tail will not reconcile until the site is upgraded.",
                    site.SiteId,
                    since,
                    afterId ?? "(none)");
                return;
            }
        }

        // Hit the within-tick page ceiling while MoreAvailable was still true:
        // the cursor advanced each page (so the backlog IS draining), there is
        // simply more than MaxReconciliationPagesPerTick × batch-size of it. The
        // next tick resumes from the advanced cursor.
        _logger.LogInformation(
            "SiteCallAudit reconciliation for site {SiteId} hit the per-tick page ceiling "
            + "({MaxPages} pages); the cursor advanced each page and the remaining backlog "
            + "drains on the next tick.",
            site.SiteId,
            MaxReconciliationPagesPerTick);
    }

    /// <summary>
    /// Latches the per-site reconciliation pinned state and publishes
    /// <see cref="SiteCallReconciliationPinnedChanged"/> on the actor system
    /// EventStream ONLY on a transition — mirroring
    /// <c>SiteAuditReconciliationActor.UpdateStalledState</c> so a downstream
    /// health subscriber never sees a flood of same-state notifications.
    /// </summary>
    private void SetReconciliationPinned(string siteId, bool pinned)
    {
        var was = _reconciliationPinned.TryGetValue(siteId, out var prior) && prior;
        if (was == pinned)
        {
            return;
        }

        _reconciliationPinned[siteId] = pinned;

        // _eventStream, not Context.System.EventStream: this runs on the
        // off-mailbox reconciliation pass, where Context must not be touched.
        _eventStream.Publish(new SiteCallReconciliationPinnedChanged(siteId, pinned));
    }

    // ── Piece B: daily terminal-row purge scheduler ──

    /// <summary>
    /// One purge pass: drops terminal <c>SiteCalls</c> rows whose
    /// <see cref="SiteCall.TerminalAtUtc"/> is older than
    /// <c>UtcNow - RetentionDays</c> via
    /// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/>. Non-terminal
    /// rows are never purged (enforced in the repository). The threshold is
    /// computed each tick so an operator who lowers <c>RetentionDays</c> sees it
    /// applied on the next purge without an actor restart. Mirrors
    /// <c>AuditLogPurgeActor</c>'s daily cadence + continue-on-error posture: a
    /// purge fault is logged and swallowed so the singleton stays alive.
    /// </summary>
    private async Task OnPurgeTickAsync()
    {
        var threshold = DateTime.UtcNow - TimeSpan.FromDays(_options.RetentionDays);

        // Open the scope INLINE with CreateAsyncScope + await using
        // so the scoped EF Core repository (an IAsyncDisposable DbContext) disposes
        // asynchronously rather than blocking the Akka dispatcher thread on a
        // synchronous Dispose(). Mirrors SiteAuditReconciliationActor; the
        // injected-repository test path reuses the test repo with no scope.
        if (_injectedRepository is not null)
        {
            await PurgeWithRepositoryAsync(_injectedRepository, threshold).ConfigureAwait(false);
            return;
        }

        await using var scope = _serviceProvider!.CreateAsyncScope();
        var repository = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
        await PurgeWithRepositoryAsync(repository, threshold).ConfigureAwait(false);
    }

    /// <summary>
    /// Runs one terminal-row purge against the resolved repository, logging and
    /// swallowing any fault (continue-on-error) so a transient SQL failure or
    /// contention never crashes the central singleton — the next tick retries
    /// the same window.
    /// </summary>
    private async Task PurgeWithRepositoryAsync(ISiteCallAuditRepository repository, DateTime threshold)
    {
        try
        {
            var rowsDeleted = await repository.PurgeTerminalAsync(threshold).ConfigureAwait(false);
            if (rowsDeleted > 0)
            {
                _logger.LogInformation(
                    "SiteCallAudit purged {RowsDeleted} terminal SiteCalls rows older than {ThresholdUtc:o}.",
                    rowsDeleted,
                    threshold);
            }
        }
        catch (Exception ex)
        {
            // Continue-on-error: a purge fault (transient SQL failure,
            // contention) must NOT crash the central singleton. The next tick
            // retries the same window.
            _logger.LogError(
                ex,
                "SiteCallAudit terminal-row purge failed (threshold {ThresholdUtc:o}); will retry next tick.",
                threshold);
        }
    }

    // ── Read-side (query / detail / KPI) ──

    /// <summary>
    /// Handles a paginated, filtered query over the <c>SiteCalls</c> table.
    /// Builds a <see cref="SiteCallQueryFilter"/> + <see cref="SiteCallPaging"/>
    /// keyset cursor from the request, runs the query on a scoped repository,
    /// and pipes the mapped response back to the captured sender. A repository
    /// fault yields a failure response with an empty list.
    /// </summary>
    private void HandleQuery(SiteCallQueryRequest request)
    {
        var sender = Sender;
        var now = DateTime.UtcNow;

        QueryAsync(request, now).PipeTo(
            sender,
            success: response => response,
            failure: ex => new SiteCallQueryResponse(
                request.CorrelationId,
                Success: false,
                ErrorMessage: ex.GetBaseException().Message,
                SiteCalls: Array.Empty<SiteCallSummary>(),
                NextAfterCreatedAtUtc: null,
                NextAfterId: null));
    }

    private async Task<SiteCallQueryResponse> QueryAsync(SiteCallQueryRequest request, DateTime now)
    {
        var stuckCutoff = now - _options.StuckAgeThreshold;

        var filter = new SiteCallQueryFilter(
            Channel: NullIfBlank(request.ChannelFilter),
            SourceSite: NullIfBlank(request.SourceSiteFilter),
            Status: NullIfBlank(request.StatusFilter),
            Target: NullIfBlank(request.TargetKeyword),
            FromUtc: request.FromUtc,
            ToUtc: request.ToUtc,
            // StuckOnly is pushed into the repository SQL via StuckCutoffUtc —
            // TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff composes with the
            // keyset cursor, so the page is always honest (full pages, no empty
            // pages with a non-null next cursor).
            StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null,
            SourceNode: NullIfBlank(request.SourceNodeFilter));

        var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
        var paging = new SiteCallPaging(
            PageSize: pageSize,
            AfterCreatedAtUtc: request.AfterCreatedAtUtc,
            AfterId: request.AfterId is { } id ? new TrackedOperationId(id) : null);

        var (scope, repository) = ResolveRepository();
        try
        {
            var rows = await repository.QueryAsync(filter, paging).ConfigureAwait(false);

            var summaries = rows
                .Select(row => ToSummary(row, stuckCutoff))
                .ToList();

            // The next-page cursor is the last row of the materialised page.
            var cursorRow = rows.Count > 0 ? rows[^1] : null;

            return new SiteCallQueryResponse(
                request.CorrelationId,
                Success: true,
                ErrorMessage: null,
                SiteCalls: summaries,
                NextAfterCreatedAtUtc: cursorRow?.CreatedAtUtc,
                NextAfterId: cursorRow?.TrackedOperationId.Value);
        }
        finally
        {
            scope?.Dispose();
        }
    }

    /// <summary>
    /// Handles a full-detail query for a single cached call — backs the report
    /// detail modal. A missing row yields <c>Success=false</c> with a "not
    /// found" message; a repository fault yields <c>Success=false</c> with the
    /// fault message.
    /// </summary>
    private void HandleDetail(SiteCallDetailRequest request)
    {
        var sender = Sender;

        DetailAsync(request).PipeTo(
            sender,
            success: response => response,
            failure: ex => new SiteCallDetailResponse(
                request.CorrelationId,
                Success: false,
                ErrorMessage: ex.GetBaseException().Message,
                Detail: null));
    }

    private async Task<SiteCallDetailResponse> DetailAsync(SiteCallDetailRequest request)
    {
        var (scope, repository) = ResolveRepository();
        try
        {
            var row = await repository
                .GetAsync(new TrackedOperationId(request.TrackedOperationId))
                .ConfigureAwait(false);

            if (row is null)
            {
                return new SiteCallDetailResponse(
                    request.CorrelationId,
                    Success: false,
                    ErrorMessage: "site call not found",
                    Detail: null);
            }

            return new SiteCallDetailResponse(
                request.CorrelationId,
                Success: true,
                ErrorMessage: null,
                Detail: ToDetail(row));
        }
        finally
        {
            scope?.Dispose();
        }
    }

    /// <summary>
    /// Handles a global KPI snapshot request, deriving the stuck cutoff from
    /// <see cref="SiteCallAuditOptions.StuckAgeThreshold"/> and the
    /// failed/delivered interval bound from <see cref="SiteCallAuditOptions.KpiInterval"/>.
    /// </summary>
    private void HandleKpi(SiteCallKpiRequest request)
    {
        var sender = Sender;
        var now = DateTime.UtcNow;
        var stuckCutoff = now - _options.StuckAgeThreshold;
        var intervalSince = now - _options.KpiInterval;

        KpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
            sender,
            success: response => response,
            failure: ex => new SiteCallKpiResponse(
                request.CorrelationId,
                Success: false,
                ErrorMessage: ex.GetBaseException().Message,
                BufferedCount: 0,
                ParkedCount: 0,
                FailedLastInterval: 0,
                DeliveredLastInterval: 0,
                OldestPendingAge: null,
                StuckCount: 0));
    }

    private async Task<SiteCallKpiResponse> KpiAsync(
        string correlationId, DateTime stuckCutoff, DateTime intervalSince)
    {
        var (scope, repository) = ResolveRepository();
        try
        {
            var snapshot = await repository
                .ComputeKpisAsync(stuckCutoff, intervalSince)
                .ConfigureAwait(false);

            return new SiteCallKpiResponse(
                correlationId,
                Success: true,
                ErrorMessage: null,
                snapshot.BufferedCount,
                snapshot.ParkedCount,
                snapshot.FailedLastInterval,
                snapshot.DeliveredLastInterval,
                snapshot.OldestPendingAge,
                snapshot.StuckCount);
        }
        finally
        {
            scope?.Dispose();
        }
    }

    /// <summary>
    /// Handles a per-source-site KPI request, using the same stuck cutoff and
    /// interval bound as <see cref="HandleKpi"/>.
    /// </summary>
    private void HandlePerSiteKpi(PerSiteSiteCallKpiRequest request)
    {
        var sender = Sender;
        var now = DateTime.UtcNow;
        var stuckCutoff = now - _options.StuckAgeThreshold;
        var intervalSince = now - _options.KpiInterval;

        PerSiteKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
            sender,
            success: response => response,
            failure: ex => new PerSiteSiteCallKpiResponse(
                request.CorrelationId,
                Success: false,
                ErrorMessage: ex.GetBaseException().Message,
                Sites: Array.Empty<SiteCallSiteKpiSnapshot>()));
    }

    private async Task<PerSiteSiteCallKpiResponse> PerSiteKpiAsync(
        string correlationId, DateTime stuckCutoff, DateTime intervalSince)
    {
        var (scope, repository) = ResolveRepository();
        try
        {
            var sites = await repository
                .ComputePerSiteKpisAsync(stuckCutoff, intervalSince)
                .ConfigureAwait(false);

            return new PerSiteSiteCallKpiResponse(
                correlationId, Success: true, ErrorMessage: null, sites);
        }
        finally
        {
            scope?.Dispose();
        }
    }

    /// <summary>
    /// Handles a per-node KPI request, using the same stuck cutoff and
    /// interval bound as <see cref="HandleKpi"/>. Additive alongside
    /// <see cref="HandlePerSiteKpi"/> — does not change per-site behaviour.
    /// </summary>
    private void HandlePerNodeKpi(PerNodeSiteCallKpiRequest request)
    {
        var sender = Sender;
        var now = DateTime.UtcNow;
        var stuckCutoff = now - _options.StuckAgeThreshold;
        var intervalSince = now - _options.KpiInterval;

        PerNodeKpiAsync(request.CorrelationId, stuckCutoff, intervalSince).PipeTo(
            sender,
            success: response => response,
            failure: ex => new PerNodeSiteCallKpiResponse(
                request.CorrelationId,
                Success: false,
                ErrorMessage: ex.GetBaseException().Message,
                Nodes: Array.Empty<SiteCallNodeKpiSnapshot>()));
    }

    private async Task<PerNodeSiteCallKpiResponse> PerNodeKpiAsync(
        string correlationId, DateTime stuckCutoff, DateTime intervalSince)
    {
        var (scope, repository) = ResolveRepository();
        try
        {
            var nodes = await repository
                .ComputePerNodeKpisAsync(stuckCutoff, intervalSince)
                .ConfigureAwait(false);

            return new PerNodeSiteCallKpiResponse(
                correlationId, Success: true, ErrorMessage: null, nodes);
        }
        finally
        {
            scope?.Dispose();
        }
    }

    // ── Central→site Retry/Discard relay ──

    /// <summary>
    /// Relays an operator Retry of a parked cached call to its owning site. The
    /// site is the source of truth — this handler NEVER writes the central
    /// <c>SiteCalls</c> mirror row. It wraps a <see cref="RetryParkedOperation"/>
    /// in a <see cref="SiteEnvelope"/> addressed to <c>SourceSite</c>, Asks the
    /// <c>CentralCommunicationActor</c> (which routes it over the per-site
    /// gRPC transport), and maps the site's
    /// <see cref="ParkedOperationActionAck"/> — or an Ask timeout — onto a
    /// <see cref="RetrySiteCallResponse"/>. A timeout / no-route is reported as
    /// the distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/> outcome,
    /// not a generic failure, so the Central UI can tell "site offline" from
    /// "operation failed".
    /// </summary>
    private void HandleRetrySiteCall(RetrySiteCallRequest request)
    {
        var sender = Sender;

        if (_centralCommunication is null)
        {
            // No transport registered yet — there is genuinely no route to any
            // site, so the only honest answer is unreachable.
            _logger.LogWarning(
                "RetrySiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
                + "central→site transport was registered; reporting site unreachable",
                request.TrackedOperationId, request.SourceSite);
            sender.Tell(UnreachableRetry(request.CorrelationId));
            return;
        }

        var relay = new RetryParkedOperation(
            request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
        var envelope = new SiteEnvelope(request.SourceSite, relay);

        RelayRetryAsync(request, envelope).PipeTo(sender);
    }

    /// <summary>
    /// Awaits the site's ack for a Retry relay, maps it onto the response, and —
    /// on an <see cref="SiteCallRelayOutcome.Applied"/> outcome with an audit sink
    /// present — emits ONE best-effort operator-identity audit row. The
    /// relay outcome is authoritative; the audit write never changes it.
    /// </summary>
    private async Task<RetrySiteCallResponse> RelayRetryAsync(RetrySiteCallRequest request, SiteEnvelope envelope)
    {
        ParkedOperationActionAck ack;
        try
        {
            ack = await _centralCommunication!
                .Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
                .ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            return MapRetryFailure(request.CorrelationId, request.SourceSite, ex);
        }

        var response = MapRetryResponse(request.CorrelationId, ack);
        if (response.Outcome == SiteCallRelayOutcome.Applied)
        {
            await EmitRelayAuditAsync(
                request.TrackedOperationId, request.RequestedBy, AuditStatus.Submitted).ConfigureAwait(false);
        }

        return response;
    }

    /// <summary>
    /// Relays an operator Discard of a parked cached call to its owning site.
    /// Mirrors <see cref="HandleRetrySiteCall"/> — see that method for the
    /// source-of-truth and site-unreachable rationale.
    /// </summary>
    private void HandleDiscardSiteCall(DiscardSiteCallRequest request)
    {
        var sender = Sender;

        if (_centralCommunication is null)
        {
            _logger.LogWarning(
                "DiscardSiteCall {TrackedOperationId} for site {SourceSite} arrived before the "
                + "central→site transport was registered; reporting site unreachable",
                request.TrackedOperationId, request.SourceSite);
            sender.Tell(UnreachableDiscard(request.CorrelationId));
            return;
        }

        var relay = new DiscardParkedOperation(
            request.CorrelationId, new TrackedOperationId(request.TrackedOperationId));
        var envelope = new SiteEnvelope(request.SourceSite, relay);

        RelayDiscardAsync(request, envelope).PipeTo(sender);
    }

    /// <summary>
    /// Awaits the site's ack for a Discard relay, maps it onto the response, and —
    /// on an <see cref="SiteCallRelayOutcome.Applied"/> outcome with an audit sink
    /// present — emits ONE best-effort operator-identity audit row.
    /// </summary>
    private async Task<DiscardSiteCallResponse> RelayDiscardAsync(DiscardSiteCallRequest request, SiteEnvelope envelope)
    {
        ParkedOperationActionAck ack;
        try
        {
            ack = await _centralCommunication!
                .Ask<ParkedOperationActionAck>(envelope, _options.RelayTimeout)
                .ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            return MapDiscardFailure(request.CorrelationId, request.SourceSite, ex);
        }

        var response = MapDiscardResponse(request.CorrelationId, ack);
        if (response.Outcome == SiteCallRelayOutcome.Applied)
        {
            await EmitRelayAuditAsync(
                request.TrackedOperationId, request.RequestedBy, AuditStatus.Discarded).ConfigureAwait(false);
        }

        return response;
    }

    /// <summary>
    /// Emits one best-effort central direct-write <see cref="AuditKind.CachedResolve"/>
    /// row attributing an operator Retry/Discard relay to <paramref name="actor"/>. The
    /// audited <see cref="AuditChannel"/>, target, source site and node are read from the
    /// stored <c>SiteCalls</c> mirror row (a benign READ — central still never mutates the
    /// mirror). No-op when no <see cref="ICentralAuditWriter"/> is registered. A missing
    /// row or a writer fault is swallowed and logged — the relay outcome is authoritative.
    /// </summary>
    private async Task EmitRelayAuditAsync(Guid trackedOperationId, string? actor, AuditStatus status)
    {
        if (_auditWriter is null)
        {
            return;
        }

        var (scope, repository) = ResolveRepository();
        try
        {
            var row = await repository
                .GetAsync(new TrackedOperationId(trackedOperationId))
                .ConfigureAwait(false);

            // SiteCall.Channel is the string form of an AuditChannel ("ApiOutbound"/
            // "DbOutbound"); fall back to ApiOutbound if the row is missing or the
            // string is unrecognised so the row still records who asked.
            var channel = row is not null && Enum.TryParse<AuditChannel>(row.Channel, out var parsed)
                ? parsed
                : AuditChannel.ApiOutbound;

            var evt = ScadaBridgeAuditEventFactory.Create(
                channel: channel,
                kind: AuditKind.CachedResolve,
                status: status,
                occurredAtUtc: DateTime.UtcNow,
                actor: actor,
                target: row?.Target,
                sourceNode: row?.SourceNode,
                correlationId: trackedOperationId,
                sourceSiteId: row?.SourceSite);

            await _auditWriter.WriteAsync(evt).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(
                ex,
                "Failed to emit operator relay audit row for {TrackedOperationId}.",
                trackedOperationId);
        }
        finally
        {
            scope?.Dispose();
        }
    }

    /// <summary>
    /// Maps the site's <see cref="ParkedOperationActionAck"/> for a Retry onto a
    /// <see cref="RetrySiteCallResponse"/>: an applied action is
    /// <see cref="SiteCallRelayOutcome.Applied"/>; a clean no-op
    /// (<c>Applied=false</c>, no error) is <see cref="SiteCallRelayOutcome.NotParked"/>;
    /// an ack carrying an error is <see cref="SiteCallRelayOutcome.OperationFailed"/>
    /// — in every case the site WAS reached.
    /// </summary>
    private static RetrySiteCallResponse MapRetryResponse(string correlationId, ParkedOperationActionAck ack)
    {
        var outcome = ClassifyAck(ack);
        return new RetrySiteCallResponse(
            correlationId,
            outcome,
            Success: outcome == SiteCallRelayOutcome.Applied,
            SiteReachable: true,
            ErrorMessage: AckErrorMessage(outcome, ack));
    }

    private static DiscardSiteCallResponse MapDiscardResponse(string correlationId, ParkedOperationActionAck ack)
    {
        var outcome = ClassifyAck(ack);
        return new DiscardSiteCallResponse(
            correlationId,
            outcome,
            Success: outcome == SiteCallRelayOutcome.Applied,
            SiteReachable: true,
            ErrorMessage: AckErrorMessage(outcome, ack));
    }

    private RetrySiteCallResponse MapRetryFailure(string correlationId, string sourceSite, Exception ex)
    {
        _logger.LogWarning(ex,
            "Retry relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
        return UnreachableRetry(correlationId);
    }

    private DiscardSiteCallResponse MapDiscardFailure(string correlationId, string sourceSite, Exception ex)
    {
        _logger.LogWarning(ex,
            "Discard relay to site {SourceSite} did not complete; reporting site unreachable", sourceSite);
        return UnreachableDiscard(correlationId);
    }

    /// <summary>
    /// Classifies a site ack: <c>Applied=true</c> → applied; <c>Applied=false</c>
    /// with no error → the site definitively had nothing parked; <c>Applied=false</c>
    /// with an error → the site could not apply the action.
    /// </summary>
    private static SiteCallRelayOutcome ClassifyAck(ParkedOperationActionAck ack)
    {
        if (ack.Applied)
        {
            return SiteCallRelayOutcome.Applied;
        }

        return ack.ErrorMessage is null
            ? SiteCallRelayOutcome.NotParked
            : SiteCallRelayOutcome.OperationFailed;
    }

    private static string? AckErrorMessage(SiteCallRelayOutcome outcome, ParkedOperationActionAck ack)
    {
        return outcome switch
        {
            SiteCallRelayOutcome.Applied => null,
            SiteCallRelayOutcome.NotParked =>
                "The operation is no longer parked at the site (already delivered, discarded, or retrying).",
            SiteCallRelayOutcome.OperationFailed => ack.ErrorMessage,
            // SiteUnreachable is never produced from a ParkedOperationActionAck —
            // unreachable responses are built by UnreachableRetry/UnreachableDiscard
            // before any ack is classified, so this arm is unreachable by construction.
            // We deliberately return ack.ErrorMessage (rather than throwing) to keep
            // AckErrorMessage total and side-effect-free: site-unreachable is classified
            // as transient by the upstream relay path (which has already constructed the
            // SiteUnreachable response and detail text via SiteUnreachableMessage), so a
            // defensive fall-through here just surfaces whatever error text the ack
            // carries and lets the caller schedule a retry. Throwing would turn a benign
            // refactor invariant violation into a relay-path crash.
            SiteCallRelayOutcome.SiteUnreachable => ack.ErrorMessage,
            _ => throw new ArgumentOutOfRangeException(
                nameof(outcome), outcome, "unknown SiteCallRelayOutcome"),
        };
    }

    /// <summary>Shared "site unreachable" detail text for both relay directions.</summary>
    private const string SiteUnreachableMessage =
        "The owning site is unreachable; the action was not applied. Retry when the site is back online.";

    private static RetrySiteCallResponse UnreachableRetry(string correlationId)
    {
        return new RetrySiteCallResponse(
            correlationId,
            SiteCallRelayOutcome.SiteUnreachable,
            Success: false,
            SiteReachable: false,
            ErrorMessage: SiteUnreachableMessage);
    }

    private static DiscardSiteCallResponse UnreachableDiscard(string correlationId)
    {
        return new DiscardSiteCallResponse(
            correlationId,
            SiteCallRelayOutcome.SiteUnreachable,
            Success: false,
            SiteReachable: false,
            ErrorMessage: SiteUnreachableMessage);
    }

    /// <summary>
    /// Resolves an <see cref="ISiteCallAuditRepository"/> for one read message.
    /// In test mode the injected instance is returned with a null scope; in
    /// production a fresh DI scope is created and returned so the caller can
    /// dispose it once the read completes — the same scope-per-message pattern
    /// as <see cref="OnUpsertAsync"/>.
    /// </summary>
    private (IServiceScope? Scope, ISiteCallAuditRepository Repository) ResolveRepository()
    {
        if (_injectedRepository is not null)
        {
            return (null, _injectedRepository);
        }

        var scope = _serviceProvider!.CreateScope();
        return (scope, scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>());
    }

    /// <summary>
    /// A cached call counts as stuck when it is still non-terminal and was
    /// created before <paramref name="stuckCutoff"/>. Non-terminal is keyed off
    /// <see cref="SiteCall.TerminalAtUtc"/> being <c>null</c> — the
    /// <c>SiteCalls</c> operational mirror stores <c>AuditStatus</c>-derived
    /// status strings (<c>Attempted</c>/<c>Delivered</c>/<c>Parked</c>/...), not
    /// the tracking-lifecycle <c>Pending</c>/<c>Retrying</c> names the spec's
    /// KPI section uses, so there is no status string that means "buffered".
    /// <c>TerminalAtUtc</c> is the entity's own active/terminal discriminator
    /// and is consistent with the repository KPI counts and
    /// <c>PurgeTerminalAsync</c>.
    /// </summary>
    private static bool IsStuck(SiteCall row, DateTime stuckCutoff)
    {
        return row.TerminalAtUtc is null && row.CreatedAtUtc < stuckCutoff;
    }

    private static SiteCallSummary ToSummary(SiteCall row, DateTime stuckCutoff)
    {
        return new SiteCallSummary(
            TrackedOperationId: row.TrackedOperationId.Value,
            SourceSite: row.SourceSite,
            Channel: row.Channel,
            Target: row.Target,
            Status: row.Status,
            RetryCount: row.RetryCount,
            LastError: row.LastError,
            HttpStatus: row.HttpStatus,
            CreatedAtUtc: row.CreatedAtUtc,
            UpdatedAtUtc: row.UpdatedAtUtc,
            TerminalAtUtc: row.TerminalAtUtc,
            IsStuck: IsStuck(row, stuckCutoff),
            SourceNode: row.SourceNode);
    }

    private static SiteCallDetail ToDetail(SiteCall row)
    {
        return new SiteCallDetail(
            TrackedOperationId: row.TrackedOperationId.Value,
            SourceSite: row.SourceSite,
            Channel: row.Channel,
            Target: row.Target,
            Status: row.Status,
            RetryCount: row.RetryCount,
            LastError: row.LastError,
            HttpStatus: row.HttpStatus,
            CreatedAtUtc: row.CreatedAtUtc,
            UpdatedAtUtc: row.UpdatedAtUtc,
            TerminalAtUtc: row.TerminalAtUtc,
            IngestedAtUtc: row.IngestedAtUtc,
            SourceNode: row.SourceNode);
    }

    /// <summary>
    /// Treats an empty/whitespace filter string as "no constraint" — the
    /// repository's <see cref="SiteCallQueryFilter"/> interprets <c>null</c> as
    /// a no-op predicate, so a blank UI filter must collapse to <c>null</c>.
    /// </summary>
    private static string? NullIfBlank(string? value)
    {
        return string.IsNullOrWhiteSpace(value) ? null : value;
    }

    /// <summary>Self-tick triggering a reconciliation pass across all sites (Piece A).</summary>
    internal sealed class ReconciliationTick
    {
        public static readonly ReconciliationTick Instance = new();
        private ReconciliationTick() { }
    }

    /// <summary>Self-tick triggering a terminal-row purge pass (Piece B).</summary>
    internal sealed class PurgeTick
    {
        public static readonly PurgeTick Instance = new();
        private PurgeTick() { }
    }

    /// <summary>
    /// Piped back to <c>Self</c> when an off-mailbox reconciliation pass ends
    /// (successfully or not) so the single-flight guard is lowered on the actor
    /// thread rather than from the background task.
    /// </summary>
    internal sealed class ReconciliationComplete
    {
        public static readonly ReconciliationComplete Instance = new();
        private ReconciliationComplete() { }
    }

    /// <summary>Purge counterpart of <see cref="ReconciliationComplete"/>.</summary>
    internal sealed class PurgeComplete
    {
        public static readonly PurgeComplete Instance = new();
        private PurgeComplete() { }
    }

    /// <summary>
    /// Serializing wrapper applied to a repository handed in through the
    /// test constructors. One call at a time, in arrival order.
    /// </summary>
    /// <remarks>
    /// <para>
    /// In production every consumer of this actor's repository gets its OWN DI
    /// scope — one per message, one per background pass — so the off-mailbox
    /// reconciliation and purge passes can safely run alongside mailbox handlers:
    /// separate scopes mean separate <c>DbContext</c>s. The injected-repository
    /// constructors break that assumption: ONE instance (typically wrapping one
    /// <c>DbContext</c>) is shared by the mailbox handlers AND the background
    /// passes, and <c>DbContext</c> forbids concurrent operations — "A second
    /// operation was started on this context instance" is a hard fault, and the
    /// interleaving is nondeterministic, so it surfaces as a flaky test rather
    /// than a reliable one.
    /// </para>
    /// <para>
    /// Serializing at the CALL, not around the whole pass, is deliberate: it
    /// removes the concurrency hazard while preserving the property the
    /// off-mailbox passes exist for — a long drain (blocked in a network pull,
    /// holding no repository call) still lets ingest, query and KPI messages be
    /// answered. Suspending the mailbox for the duration of a pass would have
    /// been simpler and would have invalidated exactly those regression tests.
    /// Production is untouched: it never constructs this type.
    /// </para>
    /// </remarks>
    private sealed class SerializedRepository : ISiteCallAuditRepository
    {
        private readonly ISiteCallAuditRepository _inner;
        private readonly SemaphoreSlim _gate = new(1, 1);

        public SerializedRepository(ISiteCallAuditRepository inner) => _inner = inner;

        public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
            RunAsync(() => _inner.UpsertAsync(siteCall, ct));

        public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
            RunAsync(() => _inner.GetAsync(id, ct));

        public Task<IReadOnlyList<SiteCall>> QueryAsync(
            SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
            RunAsync(() => _inner.QueryAsync(filter, paging, ct));

        public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
            RunAsync(() => _inner.PurgeTerminalAsync(olderThanUtc, ct));

        public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
            DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
            RunAsync(() => _inner.ComputeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));

        public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
            DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
            RunAsync(() => _inner.ComputePerSiteKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));

        public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
            DateTime stuckCutoffUtc, DateTime deliveredSinceUtc, CancellationToken ct = default) =>
            RunAsync(() => _inner.ComputePerNodeKpisAsync(stuckCutoffUtc, deliveredSinceUtc, ct));

        private async Task RunAsync(Func<Task> call)
        {
            await _gate.WaitAsync().ConfigureAwait(false);
            try
            {
                await call().ConfigureAwait(false);
            }
            finally
            {
                _gate.Release();
            }
        }

        private async Task<T> RunAsync<T>(Func<Task<T>> call)
        {
            await _gate.WaitAsync().ConfigureAwait(false);
            try
            {
                return await call().ConfigureAwait(false);
            }
            finally
            {
                _gate.Release();
            }
        }
    }
}

/// <summary>
/// Registers the central→site command transport (the <c>CentralCommunicationActor</c>)
/// with the <see cref="SiteCallAuditActor"/> so it can relay Retry/Discard
/// actions on parked cached calls to their owning sites. Sent by the Host after
/// both actors exist. Lives here (not in Commons) because it carries an
/// <see cref="IActorRef"/> and <c>ZB.MOM.WW.ScadaBridge.Commons</c> has no Akka reference —
/// the same rationale as <c>RegisterAuditIngest</c>.
/// </summary>
public sealed record RegisterCentralCommunication(IActorRef CentralCommunication);
