using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
///
/// Default implementation — fetches
/// the raw series via and
/// reduces it with to at most the requested
/// (or configured-default) number of points.
///
///
///
/// Mirrors : the production constructor takes an
/// and opens a fresh DI scope per query —
/// resolving a fresh (and therefore a fresh
/// ScadaBridgeDbContext) — so a trend chart's auto-load never contends with
/// the circuit-scoped context the rest of the page uses.
///
///
/// A second constructor injects an directly — a
/// test seam (same dual-ctor pattern as ) so unit
/// tests can substitute a stub without standing up a DI container. Both ctors take
/// for the default series-point ceiling.
///
///
public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
{
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
private readonly IServiceScopeFactory? _scopeFactory;
// Test seam: a directly-injected repository whose lifetime the test owns.
// Null in the production ctor.
private readonly IKpiHistoryRepository? _injectedRepository;
private readonly KpiHistoryOptions _options;
///
/// Production constructor — resolves from a
/// fresh DI scope on every call so each query gets its own
/// ScadaBridgeDbContext and never contends with the circuit-scoped context.
///
/// Factory used to open a fresh DI scope per query.
/// KPI History options supplying the default series-point ceiling.
public KpiHistoryQueryService(
IServiceScopeFactory scopeFactory,
IOptions options)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
///
/// Test-seam constructor — injects a repository instance whose lifetime the
/// caller owns. Used by unit tests that substitute a stub repository.
///
/// The KPI history repository instance to use directly.
/// KPI History options supplying the default series-point ceiling.
public KpiHistoryQueryService(
IKpiHistoryRepository repository,
IOptions options)
{
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}
///
public async Task> GetSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default)
{
var effectiveMax = maxPoints ?? _options.DefaultMaxSeriesPoints;
// Per-metric reduction intent: the same catalog that drives the hourly fold drives
// the chart-time bucket reduction, so a Rate series is summed per bucket on BOTH the
// raw-minute and hourly-rollup routes — one unit ("events per chart bucket") on both
// sides of the RollupThresholdHours routing boundary (arch-review 04 round 2, R3).
var aggregation = KpiMetricAggregationCatalog.Resolve(source, metric);
// Test-seam ctor: use the injected repository directly.
if (_injectedRepository is not null)
{
var injectedSeries = await FetchSeriesAsync(
_injectedRepository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(injectedSeries, fromUtc, toUtc, effectiveMax, aggregation);
}
// Production: a fresh scope (and thus a fresh DbContext) per query so a
// chart's auto-load never shares the circuit-scoped context.
await using var serviceScope = _scopeFactory!.CreateAsyncScope();
var repository = serviceScope.ServiceProvider.GetRequiredService();
var series = await FetchSeriesAsync(
repository, source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
return KpiSeriesBucketer.Bucket(series, fromUtc, toUtc, effectiveMax, aggregation);
}
///
/// Routes a series fetch to the raw-sample or hourly-rollup repository method by
/// the requested window width. Windows wider than
/// read the pre-aggregated
/// hourly rollups (≈60× fewer rows scanned on 30 d/90 d ranges); windows at or below
/// the threshold read raw samples, preserving intra-minute detail on the 24 h/7 d
/// charts. Both paths return the same ascending shape, so
/// the caller's step is agnostic to the source
/// (a 90 d rollup can still exceed the point ceiling, so bucketing still applies).
/// The boundary is strict: exactly RollupThresholdHours stays on the raw path.
///
/// The routing boundary no longer changes a Rate chart's magnitude: both paths reduce to
/// per-bucket sums via the catalog-driven aggregation applied in
/// . A residual ≤~1.2× native-resolution difference exists only
/// for an unbucketed just-over-threshold rollup series (plotted at native per-hour sums) vs.
/// an at-threshold bucketed raw series — documented, not hidden (arch-review 04 round 2, R3).
///
///
private Task> FetchSeriesAsync(
IKpiHistoryRepository repository,
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken)
{
var windowHours = (toUtc - fromUtc).TotalHours;
return windowHours > _options.RollupThresholdHours
? repository.GetHourlySeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken)
: repository.GetRawSeriesAsync(source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
}
}