using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
///
/// Process-level memoizing façade over (arch-review WP2.4).
///
/// The Alarm Summary page polls every 15s per circuit, and each poll fans one debug
/// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site
/// meant ten independent fan-outs per 15s across the same instances — on top of the shared
/// live-alarm aggregator already seeding and reconciling from the identical fan-out. The
/// question is not user-specific, so this service collapses it: one memo slot per site,
/// single-flight, so the node costs ONE fan-out per site per window no matter how many
/// viewers are watching.
///
///
/// The freshness window follows the live cache. While
/// is true the page deliberately ignores the poll's
/// alarm rows (the live deltas own them — arch-review R2 N5), so the only thing the poll
/// still supplies is the not-reporting list, and the window widens to the aggregator's own
/// reconcile interval. While the cache is cold the poll is the page's full-rebuild safety
/// net, so the window stays just under the page's 15s tick and every tick gets fresh data.
///
///
public sealed class SharedAlarmSummaryService : IAlarmSummaryService
{
///
/// Freshness window while the live alarm cache is NOT serving this site: the poll is the
/// page's authoritative rebuild path, so it must stay under the 15s page tick.
///
internal static readonly TimeSpan ColdCacheTtl = TimeSpan.FromSeconds(12);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ISiteAlarmLiveCache _liveCache;
private readonly TimeSpan _liveCacheTtl;
private readonly Func? _clock;
private readonly ConcurrentDictionary> _bySite = new();
///
/// Initializes the shared alarm summary façade.
///
/// Opens a fresh DI scope per fan-out (fresh repositories, off any circuit scope).
/// The shared live alarm cache, consulted only for its per-site liveness.
/// Communication options; supplies the aggregator reconcile interval.
public SharedAlarmSummaryService(
IServiceScopeFactory scopeFactory,
ISiteAlarmLiveCache liveCache,
IOptions options)
: this(scopeFactory, liveCache, (options ?? throw new ArgumentNullException(nameof(options)))
.Value.LiveAlarmCacheReconcileInterval, clock: null)
{
}
///
/// Test seam: same façade with an explicit live-cache window and clock.
///
/// Opens a fresh DI scope per fan-out.
/// The shared live alarm cache.
/// Freshness window used while the live cache is serving the site.
/// Clock used for freshness.
internal SharedAlarmSummaryService(
IServiceScopeFactory scopeFactory,
ISiteAlarmLiveCache liveCache,
TimeSpan liveCacheTtl,
Func? clock)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_liveCache = liveCache ?? throw new ArgumentNullException(nameof(liveCache));
// Never shorter than the cold window — a misconfigured reconcile interval must not
// silently make the memo useless.
_liveCacheTtl = liveCacheTtl > ColdCacheTtl ? liveCacheTtl : ColdCacheTtl;
_clock = clock;
}
///
public Task GetSiteAlarmsAsync(
int siteId, CancellationToken cancellationToken = default)
{
var memo = _bySite.GetOrAdd(
siteId,
_ => new SingleFlightMemo(ColdCacheTtl, _clock));
var ttl = _liveCache.IsLive(siteId) ? _liveCacheTtl : ColdCacheTtl;
return memo.GetAsync(
() => FanOutAsync(siteId),
forceRefresh: false,
cancellationToken,
ttlOverride: ttl);
}
///
public AlarmSummaryResult BuildFromLiveAlarms(IReadOnlyList alarms) =>
AlarmSummaryService.BuildFromLiveAlarmsCore(alarms);
///
public AlarmRollup ComputeRollup(IReadOnlyList rows) =>
AlarmSummaryService.ComputeRollupCore(rows);
///
/// One shared fan-out. Deliberately runs with no caller cancellation token: the flight is
/// shared, so one circuit navigating away must not cancel the round the others are awaiting.
///
private async Task FanOutAsync(int siteId)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var inner = scope.ServiceProvider.GetRequiredService();
return await inner.GetSiteAlarmsAsync(siteId, CancellationToken.None);
}
}