using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
using ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter;

namespace ZB.MOM.WW.ScadaBridge.AuditLog;

/// <summary>
/// Composition root for the Audit Log component.
/// </summary>
/// <remarks>
/// <para>
/// Registered <see cref="AuditLogOptions"/> + the validator. Extends the
/// surface with the site-side writer chain
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
/// <see cref="IAuditWriteFailureCounter"/>, <see cref="SiteAuditTelemetryOptions"/>,
/// <see cref="SqliteAuditWriterOptions"/>).
/// </para>
/// <para>
/// Audit Log sits alongside Notification Outbox and Site Call
/// Audit. <c>IAuditLogRepository</c> is registered by
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so the caller (the Host on the central node) must also call that.
/// </para>
/// </remarks>
public static class ServiceCollectionExtensions
{
    /// <summary>Configuration section bound to <see cref="AuditLogOptions"/>.</summary>
    public const string ConfigSectionName = "AuditLog";

    /// <summary>Configuration section bound to <see cref="SqliteAuditWriterOptions"/>.</summary>
    public const string SiteWriterSectionName = "AuditLog:SiteWriter";

    /// <summary>Configuration section bound to <see cref="SiteAuditTelemetryOptions"/>.</summary>
    public const string SiteTelemetrySectionName = "AuditLog:SiteTelemetry";

    /// <summary>Configuration section bound to <see cref="AuditLogPartitionMaintenanceOptions"/>.</summary>
    public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";

    /// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPurgeOptions"/>.</summary>
    public const string PurgeSectionName = "AuditLog:Purge";

    /// <summary>Configuration section bound to <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.SiteAuditReconciliationOptions"/>.</summary>
    public const string ReconciliationSectionName = "AuditLog:Reconciliation";

    /// <summary>
    /// Registers the Audit Log component services: options, the site
    /// SQLite writer chain (primary + ring fallback + failure-counter sink),
    /// and the site-→central telemetry collaborators. Idempotent re-registration
    /// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
    /// </summary>
    /// <param name="services">The service collection to register into.</param>
    /// <param name="config">Application configuration used to bind <see cref="AuditLogOptions"/> and related options sections.</param>
    /// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
    public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
    {
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(config);

        // Top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
        // Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
        // "AuditLog" section, registers the validator, and enables ValidateOnStart in
        // one call. Same section path as before; AddAuditLog is call-once per
        // collection, and the helper's TryAddEnumerable is idempotent for the
        // validator (a strict improvement over the previous AddSingleton).
        services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);

        // The canonical IAuditRedactor is wired as
        // ScadaBridgeAuditRedactor — same truncation + header / body /
        // SQL-parameter redaction as the original pipeline, applied between
        // event construction and persistence. Singleton — stateless; the
        // IOptionsMonitor dependency picks up hot reloads on its own.
        services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();

        // Per-stage redactor-failure counter. NoOp default; this binding is
        // later replaced with the Site Health Monitoring bridge that surfaces
        // failures as AuditRedactionFailure on the site health report.
        services.TryAddSingleton<IAuditRedactionFailureCounter, NoOpAuditRedactionFailureCounter>();

        // Site writer + telemetry options bindings — now bind-and-validate via
        // the shared helper so a bad site config fails fast at boot (NF4). The
        // helper's GetSection(sectionPath) still resolves the section explicitly
        // (NOT BindConfiguration), preserving the partial-config-root rationale:
        // the caller's IConfiguration may be a test stub anchored on the AuditLog
        // section's parent rather than the application root.
        services.AddValidatedOptions<SqliteAuditWriterOptions, SqliteAuditWriterOptionsValidator>(
            config, SiteWriterSectionName);
        services.AddValidatedOptions<SiteAuditTelemetryOptions, SiteAuditTelemetryOptionsValidator>(
            config, SiteTelemetrySectionName);
        // Retention purge options (the SiteAuditRetentionService hosted job is
        // registered site-only in AddAuditLogHealthMetricsBridge). The binding
        // here is inert on central — no hosted service consumes it there — but
        // the defaults validate clean, so eager validation on the central root is
        // harmless.
        services.AddValidatedOptions<SiteAuditRetentionOptions, SiteAuditRetentionOptionsValidator>(
            config, SiteAuditRetentionOptions.SectionName);

        // SqliteAuditWriter is a singleton with a single owned SqliteConnection
        // and a background writer Task; multiple instances would race on the
        // same file. Registered concretely so the ISiteAuditQueue + IAuditWriter
        // forwards below resolve to the same instance — the actor must observe
        // the writes made via the hot-path interface.
        services.AddSingleton<SqliteAuditWriter>();
        services.AddSingleton<ISiteAuditQueue>(sp => sp.GetRequiredService<SqliteAuditWriter>());

        // RingBufferFallback: drop-oldest in-memory ring used by
        // FallbackAuditWriter when the primary SQLite writer throws. Default
        // capacity is fine (1024).
        services.AddSingleton<RingBufferFallback>();

        // IAuditWriteFailureCounter: NoOp default. This binding is later
        // overridden with the real Site Health Monitoring counter. Registered
        // before FallbackAuditWriter so the factory can resolve it.
        services.AddSingleton<IAuditWriteFailureCounter, NoOpAuditWriteFailureCounter>();

        // The script-thread surface is FallbackAuditWriter (primary + ring +
        // counter), not the raw SqliteAuditWriter — primary failures must NEVER
        // abort the user-facing action.
        // The canonical IAuditRedactor singleton above is wired
        // through the factory so every event written through this surface is
        // truncated + redacted before it hits SQLite (and the ring on
        // failure).
        services.AddSingleton<IAuditWriter>(sp => new FallbackAuditWriter(
            primary: sp.GetRequiredService<SqliteAuditWriter>(),
            ring: sp.GetRequiredService<RingBufferFallback>(),
            failureCounter: sp.GetRequiredService<IAuditWriteFailureCounter>(),
            logger: sp.GetRequiredService<ILogger<FallbackAuditWriter>>(),
            redactor: sp.GetRequiredService<IAuditRedactor>()));

        // ISiteStreamAuditClient: NoOp default. This binding remains correct for
        // central/test composition roots that have no SiteCommunicationActor.
        // The real implementation is SiteCommunicationAuditClient, which pushes
        // audit telemetry to central via the site's
        // SiteCommunicationActor — the Host wires it directly into the
        // SiteAuditTelemetryActor's Props.Create call for site roles (it cannot
        // be a DI singleton because it needs the SiteCommunicationActor IActorRef,
        // created during Akka bootstrap, not at DI-composition time).
        services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();

        // Site-side dual emitter for cached-call lifecycle
        // telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
        // Database.CachedWrite resolves this through DI and pushes one combined
        // packet per lifecycle event; the forwarder writes the audit half
        // through IAuditWriter and the operational half through the
        // IOperationTrackingStore. The audit writer is always wired (the
        // chain above); the operational tracking store is SITE-ONLY (registered
        // by ZB.MOM.WW.ScadaBridge.SiteRuntime). On a Central composition root the tracking
        // store has no registration, so the factory resolves it with GetService
        // (returning null) — the forwarder degrades to "audit-only" emission,
        // mirroring the lazy IAuditWriter chain established above.
        services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
            new CachedCallTelemetryForwarder(
                sp.GetRequiredService<IAuditWriter>(),
                sp.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>(),
                sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
                // INodeIdentityProvider is now required across
                // every consumer in AddAuditLog. The Host's
                // SiteServiceRegistration registers it as a singleton on both
                // site and central paths, and the AddAuditLogTests fixture provides a
                // FakeNodeIdentityProvider; a silent GetService() that
                // returned null would mask a future composition root that
                // forgot to register the provider.
                sp.GetRequiredService<INodeIdentityProvider>()));

        // Bridge the store-and-forward retry-loop observer hook
        // to the cached-call forwarder so per-attempt + terminal telemetry
        // emitted from the S&F retry sweep lands on the same SQLite hot-path
        // as the script-thread CachedSubmit row. Registered as a singleton
        // and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
        // can resolve it through DI.
        // SourceNode-stamping: factory-resolved so the
        // INodeIdentityProvider singleton can be threaded through — the
        // bridge stamps SiteCallOperational.SourceNode from
        // INodeIdentityProvider.NodeName on every cached-call lifecycle row.
        // The provider is resolved with GetRequiredService —
        // SiteServiceRegistration.BindSharedOptions registers it on both
        // site and central paths, so a missing registration is a
        // composition-root bug, not a silent null-SourceNode degradation.
        services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
            sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
            sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
            // Required, matches the other consumers in this
            // composition root — the provider is always registered by
            // SiteServiceRegistration.
            sp.GetRequiredService<INodeIdentityProvider>()));
        services.AddSingleton<ICachedCallLifecycleObserver>(
            sp => sp.GetRequiredService<CachedCallLifecycleBridge>());

        // Central audit-write failure counter — NoOp default
        // for site/test composition roots that don't wire the central health
        // snapshot. AddAuditLogCentralMaintenance below replaces this binding
        // with the AuditCentralHealthSnapshot implementation so increments
        // surface on the central dashboard.
        services.TryAddSingleton<ICentralAuditWriteFailureCounter, NoOpCentralAuditWriteFailureCounter>();

        // Inbound body-ceiling hit counter — NoOp default for
        // site/test roots. AddAuditLogCentralMaintenance replaces this binding
        // with the AuditCentralHealthSnapshot implementation so ceiling-hit
        // counts surface on the central dashboard alongside write-failure and
        // redaction-failure counters.
        services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();

        // AuditLog purge-failure counter — NoOp default for site/test
        // composition roots. AddAuditLogCentralMaintenance below replaces this binding
        // with the AuditCentralHealthSnapshot implementation so partition/channel purge
        // failures surface on the central dashboard alongside the write-failure counter.
        services.TryAddSingleton<IAuditPurgeFailureCounter, NoOpAuditPurgeFailureCounter>();

        // Central direct-write audit writer used by
        // NotificationOutboxActor and Inbound API to
        // emit AuditLog rows that originate ON central, not via site telemetry.
        // Singleton — the writer is stateless; its per-call scope opens a fresh
        // IAuditLogRepository (a SCOPED EF Core service registered by
        // ZB.MOM.WW.ScadaBridge.ConfigurationDatabase). The interface (ICentralAuditWriter)
        // is intentionally distinct from IAuditWriter so site composition roots
        // do not accidentally bind it; central composition roots that include
        // AddConfigurationDatabase get a working implementation transparently.
        // Wire the canonical IAuditRedactor into the factory so
        // NotificationOutboxActor + Inbound API rows are truncated + redacted
        // before they hit MS SQL.
        // Also wire the ICentralAuditWriteFailureCounter
        // so swallowed repo throws bump the central health counter.
        services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
            sp,
            sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
            sp.GetRequiredService<IAuditRedactor>(),
            sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
            // SourceNode-stamping: wire the local node identity so
            // central-origin rows (Notification Outbox dispatch, Inbound API)
            // carry the writing node's identifier when the caller hasn't
            // already supplied one. GetRequiredService — the production
            // composition root in SiteServiceRegistration registers the
            // provider as a singleton on both site and central paths.
            sp.GetRequiredService<INodeIdentityProvider>()));

        // KPI History & Trends: the AuditLog source the central KPI
        // recorder enumerates each sampling pass to snapshot Global-scope Audit
        // Log KPIs (volume / error rate / backlog) into the KpiSample history
        // table. Scoped to match its IAuditLogRepository dependency — a SCOPED
        // EF Core service; the recorder opens a fresh scope per sampling pass.
        // TryAdd-Enumerable so multiple sources can register the same interface
        // (NotificationOutbox + SiteCallAudit + SiteHealth add their own) without
        // any one composition root clobbering another, and re-registration is a
        // no-op if AddAuditLog were ever called twice.
        services.TryAddEnumerable(
            ServiceDescriptor.Scoped<IKpiSampleSource, AuditLogKpiSampleSource>());

        return services;
    }

    /// <summary>
    /// Swap the default
    /// <see cref="NoOpAuditWriteFailureCounter"/> and
    /// <see cref="NoOpAuditRedactionFailureCounter"/> registrations for the
    /// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
    /// <see cref="HealthMetricsAuditRedactionFailureCounter"/> bridges so the
    /// FallbackAuditWriter primary-failure counter AND the
    /// <see cref="ScadaBridgeAuditRedactor"/> redactor-failure counter both surface in the
    /// site health report payload as
    /// <c>SiteHealthReport.SiteAuditWriteFailures</c> +
    /// <c>SiteHealthReport.AuditRedactionFailure</c>.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Must be called AFTER both <see cref="AddAuditLog"/> (registers the
    /// NoOp defaults this method replaces) and
    /// <c>ZB.MOM.WW.ScadaBridge.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring</c>
    /// or <c>AddSiteHealthMonitoring</c> (registers the
    /// <see cref="ISiteHealthCollector"/> the bridges depend on). Resolving
    /// <see cref="IAuditWriteFailureCounter"/> or
    /// <see cref="IAuditRedactionFailureCounter"/> without the latter throws
    /// <see cref="InvalidOperationException"/> at <c>GetRequiredService</c>
    /// time — by design, since a silent NoOp would mask a misconfiguration.
    /// </para>
    /// <para>
    /// Idempotent — a sentinel check on the
    /// <see cref="SiteAuditBacklogReporter"/> hosted-service descriptor
    /// short-circuits subsequent calls so the hosted service is not
    /// double-registered (AddHostedService has no TryAdd variant).
    /// </para>
    /// <para>
    /// Site-side only: the central composition root keeps the NoOp
    /// defaults; the central health-metric surface that would expose
    /// <c>AuditRedactionFailure</c> next to the existing central counters
    /// ships later.
    /// </para>
    /// </remarks>
    /// <param name="services">The service collection to register into.</param>
    /// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
    public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services)
    {
        ArgumentNullException.ThrowIfNull(services);

        // Guard against double-registration. AddHostedService is
        // additive (no TryAdd variant) so a second call without this sentinel
        // would spin up a second SiteAuditBacklogReporter, doubling the 30 s
        // SQL probe rate and racing on the ISiteHealthCollector snapshot. The
        // SiteAuditBacklogReporter descriptor is the discriminator: it's only
        // registered by this helper, so its presence proves the bridge has
        // already been wired.
        if (services.Any(d => d.ImplementationType == typeof(SiteAuditBacklogReporter)))
        {
            return services;
        }

        services.Replace(
            ServiceDescriptor.Singleton<IAuditWriteFailureCounter, HealthMetricsAuditWriteFailureCounter>());
        services.Replace(
            ServiceDescriptor.Singleton<IAuditRedactionFailureCounter, HealthMetricsAuditRedactionFailureCounter>());
        // The site-side backlog reporter polls the
        // SqliteAuditWriter every 30 s and pushes the snapshot into the
        // collector so the next SiteHealthReport carries a fresh
        // SiteAuditBacklog field. Registered alongside the other site-only
        // metric bridges so AddAuditLog (which runs on central too) stays
        // free of hosted-service registrations that would resolve a missing
        // ISiteHealthCollector on central.
        services.AddHostedService<SiteAuditBacklogReporter>();
        // Site-only retention purge: runs ISiteAuditQueue.PurgeExpiredAsync on a
        // daily timer so the ephemeral site SQLite store does not grow unbounded.
        // Registered here (not in AddAuditLog, which also runs on central) so the
        // hosted service only starts on site composition roots. Guarded by the same
        // SiteAuditBacklogReporter sentinel above against double-registration.
        services.AddHostedService<SiteAuditRetentionService>();
        return services;
    }

    /// <summary>
    /// Central-only registration for the
    /// <see cref="AuditLogPartitionMaintenanceService"/> hosted service plus
    /// its <see cref="AuditLogPartitionMaintenanceOptions"/> binding. Must be
    /// called from the Central role's composition root (not from a site
    /// composition root); the underlying <c>IPartitionMaintenance</c>
    /// implementation is registered by <c>AddConfigurationDatabase</c> and
    /// only exists on the central node.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Separated from <see cref="AddAuditLog"/> because <c>AddAuditLog</c> is
    /// also invoked from site composition roots — silently starting a
    /// hosted service that resolves an unregistered dependency on a site
    /// would fail every tick. Keeping the central-only registration in its
    /// own helper preserves the "every <c>Add*</c> call is safe to issue
    /// from any composition root" invariant.
    /// </para>
    /// </remarks>
    /// <param name="services">The service collection to register into.</param>
    /// <param name="config">Application configuration used to bind partition maintenance options.</param>
    /// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
    public static IServiceCollection AddAuditLogCentralMaintenance(
        this IServiceCollection services,
        IConfiguration config)
    {
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(config);

        services.AddValidatedOptions<AuditLogPartitionMaintenanceOptions,
            AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
        services.AddHostedService<AuditLogPartitionMaintenanceService>();

        // I1 (review): bind the two central-singleton options HERE rather than in
        // AddAuditLogCentralReconciliationClient. AkkaHostedService.RegisterCentralActors
        // resolves IOptions<AuditLogPurgeOptions> / <SiteAuditReconciliationOptions>
        // via GetRequiredService when it wires the AuditLogPurgeActor +
        // SiteAuditReconciliationActor singletons; AddAuditLogCentralMaintenance is
        // ALWAYS called on the central path (the reconciliation-client helper is the
        // one that could in principle be dropped), so binding the options here means
        // the singletons get a valid IOptions even if the gRPC-client helper is not
        // wired — instead of a cryptic InvalidOperationException at GetRequiredService.
        // Defaults are fine when the section is absent (24 h purge cadence /
        // 5 min reconciliation tick); production exposes IntervalHours /
        // ReconciliationIntervalSeconds only — the test-only *Override knobs are
        // not intended to be set from config (see the options classes' remarks).
        services.AddValidatedOptions<AuditLogPurgeOptions, AuditLogPurgeOptionsValidator>(
            config, PurgeSectionName);
        services.AddValidatedOptions<SiteAuditReconciliationOptions, SiteAuditReconciliationOptionsValidator>(
            config, ReconciliationSectionName);

        // Central health snapshot — a single object
        // that owns the CentralAuditWriteFailures + AuditRedactionFailure
        // Interlocked counters AND surfaces them on
        // IAuditCentralHealthSnapshot. The same instance is bound to BOTH
        // writer-side interfaces (ICentralAuditWriteFailureCounter +
        // IAuditRedactionFailureCounter) so every central-side increment
        // routes into the shared counters; site nodes keep their existing
        // Site bridges (registered by AddAuditLogHealthMetricsBridge) so
        // the same counter type does not shadow the site-side metric.
        // The snapshot itself has no actor-system dependency — the
        // per-site stalled latch is fed by SiteAuditTelemetryStalledTracker
        // which the Akka bootstrap wires up after ActorSystem.Create returns
        // (the tracker is NOT registered here because its construction
        // requires ActorSystem, which is not a DI-resolvable singleton).
        services.AddSingleton<AuditCentralHealthSnapshot>();
        services.AddSingleton<IAuditCentralHealthSnapshot>(
            sp => sp.GetRequiredService<AuditCentralHealthSnapshot>());
        services.Replace(ServiceDescriptor.Singleton<ICentralAuditWriteFailureCounter>(
            sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
        // Override the NoOp IAuditRedactionFailureCounter
        // (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter
        // bridge so payload-filter throws on CentralAuditWriter /
        // AuditLogIngestActor paths surface on the central dashboard. The
        // bridge is a thin wrapper around the AuditCentralHealthSnapshot
        // singleton so all central redactor failures route into the same
        // counter as CentralAuditWriteFailures. The site composition root
        // overrides this binding AGAIN via AddAuditLogHealthMetricsBridge —
        // central nodes do not call that bridge, so this is the final
        // binding on a central host. Mirrors the
        // HealthMetricsAuditRedactionFailureCounter shape one-for-one.
        services.Replace(ServiceDescriptor.Singleton<IAuditRedactionFailureCounter,
            CentralAuditRedactionFailureCounter>());
        // Replace the NoOp IAuditInboundCeilingHitsCounter with the
        // AuditCentralHealthSnapshot so ceiling-hit counts surface on the
        // central dashboard. Same singleton-forward pattern as
        // ICentralAuditWriteFailureCounter above.
        services.Replace(ServiceDescriptor.Singleton<IAuditInboundCeilingHitsCounter>(
            sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
        // Replace the NoOp IAuditPurgeFailureCounter with the AuditCentralHealthSnapshot so
        // partition/channel purge failures surface on the central dashboard. Same
        // singleton-forward pattern as ICentralAuditWriteFailureCounter above (arch-review 04, S2).
        services.Replace(ServiceDescriptor.Singleton<IAuditPurgeFailureCounter>(
            sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));

        return services;
    }

    /// <summary>
    /// Audit Log — central-only registration of the production
    /// <see cref="IPullAuditEventsClient"/> (<see cref="GrpcPullAuditEventsClient"/>)
    /// and its unary-call invoker (<see cref="GrpcPullAuditEventsInvoker"/>) used
    /// by <see cref="SiteAuditReconciliationActor"/> to pull reconciliation
    /// batches from each site over the <c>PullAuditEvents</c> gRPC RPC.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Kept out of <see cref="AddAuditLog"/> — which also runs on site
    /// composition roots — because the client dials sites and resolves
    /// <see cref="ISiteEnumerator"/> (a central-only collaborator wired
    /// alongside the reconciliation singleton). Folding it into
    /// <see cref="AddAuditLog"/> would register a site-dialing client on every
    /// site host, violating the "every <c>Add*</c> call is safe from any
    /// composition root" invariant. This helper is the central analogue of
    /// <see cref="AddAuditLogCentralMaintenance"/>.
    /// </para>
    /// <para>
    /// The <see cref="GrpcPullAuditEventsInvoker"/> binds with default
    /// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions"/>
    /// keepalive unless an <c>IOptions&lt;CommunicationOptions&gt;</c> is
    /// already registered, in which case the configured timings flow through —
    /// matching how <c>SiteStreamGrpcClientFactory</c> takes its keepalive from
    /// the same options.
    /// </para>
    /// <para>
    /// The production <see cref="ISiteEnumerator"/> (<see cref="SiteEnumerator"/>,
    /// wrapping the scoped <c>ISiteRepository</c>) IS registered here — so the
    /// <see cref="SiteAuditReconciliationActor"/> singleton wired in the Host can
    /// resolve its enumerator + gRPC client from this central-only helper. Keeping
    /// the enumerator on this central path preserves the "every <c>Add*</c> call is
    /// safe from any composition root" invariant: a site host never calls this
    /// helper, so it never registers a site-dialing enumerator. The
    /// <see cref="AuditLogPurgeOptions"/> + <see cref="SiteAuditReconciliationOptions"/>
    /// bindings live in <see cref="AddAuditLogCentralMaintenance"/> instead (I1
    /// review fix) — that helper is unconditionally called on the central path, so
    /// the two maintenance singletons get a valid <c>IOptions</c> even if this
    /// gRPC-client helper is ever dropped.
    /// </para>
    /// </remarks>
    /// <param name="services">The service collection to register into.</param>
    /// <param name="config">Application configuration used to bind the gRPC client's communication options (purge + reconciliation options are bound by <see cref="AddAuditLogCentralMaintenance"/>).</param>
    /// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
    public static IServiceCollection AddAuditLogCentralReconciliationClient(
        this IServiceCollection services,
        IConfiguration config)
    {
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(config);

        // Production ISiteEnumerator: projects the config-DB Site rows into the
        // reconciliation targets the SiteAuditReconciliationActor polls. Scoped
        // ISiteRepository is resolved per call inside the enumerator, so the
        // singleton takes the ROOT provider (mirrors the per-tick scope pattern
        // in SiteAuditReconciliationActor / AuditLogPurgeActor).
        services.TryAddSingleton<ISiteEnumerator>(sp => new SiteEnumerator(sp));

        // I1 (review): the AuditLogPurgeOptions / SiteAuditReconciliationOptions
        // bindings moved to AddAuditLogCentralMaintenance — that helper is always
        // called on the central path, so the two maintenance singletons resolve a
        // valid IOptions even if this gRPC-client helper is ever dropped. Keep the
        // ISiteEnumerator + gRPC client registrations here (they dial sites and are
        // central-only by design).

        // The invoker owns the per-endpoint GrpcChannel cache, so it must be a
        // singleton — a fresh invoker per resolution would leak channels.
        // Resolve CommunicationOptions if present (the central Host binds it),
        // otherwise fall back to defaults so this helper stays standalone.
        services.TryAddSingleton<GrpcPullAuditEventsInvoker>(sp =>
        {
            var options = sp
                .GetService<Microsoft.Extensions.Options.IOptions<
                    ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
            // The PSK provider is central-only and optional in DI, so GetService (not
            // GetRequiredService): a host without one dials unauthenticated and the site
            // refuses it, which is the fail-closed outcome we want rather than a
            // resolution crash at composition time.
            var psk = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider>();
            return options is null
                ? new GrpcPullAuditEventsInvoker(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions(), psk)
                : new GrpcPullAuditEventsInvoker(options.Value, psk);
        });
        services.TryAddSingleton<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(
            sp => sp.GetRequiredService<GrpcPullAuditEventsInvoker>());

        services.TryAddSingleton<IPullAuditEventsClient>(sp => new GrpcPullAuditEventsClient(
            sp.GetRequiredService<ISiteEnumerator>(),
            sp.GetRequiredService<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(),
            sp.GetRequiredService<ILogger<GrpcPullAuditEventsClient>>()));

        // Site Call Audit reconciliation pull client — central-only, the
        // sibling of the audit pull client above. Lives here (not in the
        // SiteCallAudit project) so it can reuse the central-only
        // ISiteEnumerator registered just above; SiteCallAudit does not
        // reference AuditLog. The invoker owns the per-endpoint GrpcChannel
        // cache, so it must be a singleton (a fresh invoker per resolution
        // would leak channels). CommunicationOptions flow through when bound by
        // the central Host, else defaults — mirrors the audit invoker.
        services.TryAddSingleton<GrpcPullSiteCallsInvoker>(sp =>
        {
            var options = sp
                .GetService<Microsoft.Extensions.Options.IOptions<
                    ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions>>();
            // The PSK provider is central-only and optional in DI, so GetService (not
            // GetRequiredService): a host without one dials unauthenticated and the site
            // refuses it, which is the fail-closed outcome we want rather than a
            // resolution crash at composition time.
            var psk = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider>();
            return options is null
                ? new GrpcPullSiteCallsInvoker(new ZB.MOM.WW.ScadaBridge.Communication.CommunicationOptions(), psk)
                : new GrpcPullSiteCallsInvoker(options.Value, psk);
        });
        services.TryAddSingleton<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(
            sp => sp.GetRequiredService<GrpcPullSiteCallsInvoker>());

        services.TryAddSingleton<IPullSiteCallsClient>(sp => new GrpcPullSiteCallsClient(
            sp.GetRequiredService<ISiteEnumerator>(),
            sp.GetRequiredService<GrpcPullSiteCallsClient.IPullSiteCallsInvoker>(),
            sp.GetRequiredService<ILogger<GrpcPullSiteCallsClient>>()));

        return services;
    }
}
