namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;

/// <summary>
/// Pure, deterministic downsampling helper for KPI series charting ("KPI History &amp; Trends").
/// Reduces a raw <see cref="KpiSeriesPoint"/> series to at most <c>maxPoints</c> points using
/// <em>per-metric</em> bucket reduction driven by <see cref="KpiRollupAggregation"/>:
/// <see cref="KpiRollupAggregation.Gauge"/> (the default) keeps the <strong>last value</strong>
/// in each bucket — suitable for step/area charts where the most recent reading best represents
/// the window; <see cref="KpiRollupAggregation.Rate"/> <strong>sums per bucket</strong> — each
/// raw point of a Rate series is a per-interval delta, so the bucket's true total is the sum of
/// its deltas. Summing on both the raw-minute and hourly-rollup paths charts the same unit
/// ("events per chart bucket") on both sides of the raw/rollup routing boundary, eliminating the
/// ~60× magnitude jump at that boundary (arch-review 04 round 2, R3).
/// </summary>
public static class KpiSeriesBucketer
{
    /// <summary>
    /// Reduces <paramref name="raw"/> to at most <paramref name="maxPoints"/> points by dividing
    /// the window <c>[<paramref name="fromUtc"/>, <paramref name="toUtc"/>]</c> into equal-width
    /// time buckets and keeping the <em>last</em> raw point (highest
    /// <see cref="KpiSeriesPoint.BucketStartUtc"/>) within each non-empty bucket.
    /// Empty buckets are omitted — no gap-filling.
    /// </summary>
    /// <param name="raw">
    ///   Input series, which must be sorted ascending by <see cref="KpiSeriesPoint.BucketStartUtc"/>.
    ///   For sorted input the last point in iteration order within a bucket is the one with the
    ///   largest timestamp (the intended last-value-per-bucket result). For unsorted input the
    ///   method still selects the last point in iteration order within each bucket — it does
    ///   <em>not</em> pick the largest-timestamp point — so the result is well-defined but not
    ///   the last-value semantics callers expect; pre-sort the series first.
    ///   If <c>null</c> or empty, an empty list is returned.
    /// </param>
    /// <param name="fromUtc">UTC start of the query window (inclusive).</param>
    /// <param name="toUtc">UTC end of the query window (inclusive on the right edge).</param>
    /// <param name="maxPoints">Maximum number of output points. Must be ≥ 2.</param>
    /// <param name="aggregation">
    ///   How each bucket is reduced. <see cref="KpiRollupAggregation.Gauge"/> (default) keeps the
    ///   last value in the bucket — the historical behavior. <see cref="KpiRollupAggregation.Rate"/>
    ///   sums the values in the bucket — each raw point of a Rate series is a per-interval delta, so
    ///   the bucket total is the sum of its deltas (arch-review 04 round 2, R3).
    /// </param>
    /// <returns>
    ///   An <see cref="IReadOnlyList{T}"/> of at most <paramref name="maxPoints"/> bucketed points,
    ///   ordered by <see cref="KpiSeriesPoint.BucketStartUtc"/> ascending.
    ///   For <see cref="KpiRollupAggregation.Gauge"/> returns <paramref name="raw"/> unchanged
    ///   (same reference) when <c>raw.Count &lt;= maxPoints</c>; callers must not mutate the
    ///   underlying collection in that case. <see cref="KpiRollupAggregation.Rate"/> always runs
    ///   the bucketing pass — two deltas landing in one bucket must sum even in a short series —
    ///   so it never returns the input reference.
    /// </returns>
    /// <exception cref="ArgumentOutOfRangeException">
    ///   Thrown when <paramref name="maxPoints"/> &lt; 2 or
    ///   <paramref name="toUtc"/> &lt;= <paramref name="fromUtc"/>.
    ///   These are caller programming errors — a chart needs at least two points and a
    ///   non-degenerate window.
    /// </exception>
    public static IReadOnlyList<KpiSeriesPoint> Bucket(
        IReadOnlyList<KpiSeriesPoint> raw,
        DateTime fromUtc,
        DateTime toUtc,
        int maxPoints,
        KpiRollupAggregation aggregation = KpiRollupAggregation.Gauge)
    {
        if (maxPoints < 2)
            throw new ArgumentOutOfRangeException(nameof(maxPoints),
                maxPoints, "maxPoints must be >= 2.");

        if (toUtc <= fromUtc)
            throw new ArgumentOutOfRangeException(nameof(toUtc),
                toUtc, "toUtc must be strictly greater than fromUtc.");

        // Normal runtime case — empty series: return as-is.
        if (raw is null || raw.Count == 0)
            return Array.Empty<KpiSeriesPoint>();

        // Short-series early return is Gauge-only: for Rate, two deltas landing in one bucket
        // must still sum, so a short Rate series cannot be returned verbatim (arch-review 04
        // round 2, R3). For well-spaced short Rate series the per-point buckets make the sum an
        // identity, so the output still equals the input anyway.
        if (aggregation == KpiRollupAggregation.Gauge && raw.Count <= maxPoints)
            return raw;

        // Divide the window into maxPoints equal-width buckets.
        // Each bucket covers [bucketStart, bucketStart + bucketWidth).
        // The right edge (toUtc) belongs to the last bucket to avoid overflow.
        double windowTicks = (double)(toUtc.Ticks - fromUtc.Ticks);
        double bucketWidthTicks = windowTicks / maxPoints;

        // For each bucket, track the candidate point: for Gauge the one with the maximum
        // BucketStartUtc (last value within the bucket); for Rate a running sum of the bucket's
        // deltas. Fixed-size arrays indexed by bucket number.
        var best = new KpiSeriesPoint[maxPoints];
        var sums = new double[maxPoints];
        var occupied = new bool[maxPoints];

        foreach (var point in raw)
        {
            long offsetTicks = point.BucketStartUtc.Ticks - fromUtc.Ticks;

            // Skip points outside [fromUtc, toUtc].
            if (offsetTicks < 0 || point.BucketStartUtc > toUtc)
                continue;

            // Compute bucket index; clamp to last bucket so toUtc itself
            // doesn't overflow to index maxPoints.
            int bucketIndex = (int)(offsetTicks / bucketWidthTicks);
            if (bucketIndex >= maxPoints)
                bucketIndex = maxPoints - 1;

            if (aggregation == KpiRollupAggregation.Rate)
            {
                // Rate: accumulate the bucket's per-interval deltas.
                sums[bucketIndex] += point.Value;
                occupied[bucketIndex] = true;
            }
            // Keep the last point in iteration order within this bucket. Because the stored
            // candidate's BucketStartUtc is the bucket-START timestamp (not the raw point's
            // capture time), the comparison below is true for essentially any in-bucket point,
            // so each later-in-iteration point overwrites the previous one. For the ascending-
            // sorted input this method requires, last-in-iteration IS the largest-timestamp
            // point — i.e. last-value-per-bucket semantics.
            else if (!occupied[bucketIndex] ||
                point.BucketStartUtc > best[bucketIndex].BucketStartUtc)
            {
                best[bucketIndex] = new KpiSeriesPoint(
                    fromUtc + TimeSpan.FromTicks((long)(bucketIndex * bucketWidthTicks)),
                    point.Value);
                occupied[bucketIndex] = true;
            }
        }

        // Collect non-empty buckets in order.
        var result = new List<KpiSeriesPoint>(maxPoints);
        for (int i = 0; i < maxPoints; i++)
        {
            if (!occupied[i])
                continue;

            result.Add(aggregation == KpiRollupAggregation.Rate
                ? new KpiSeriesPoint(
                    fromUtc + TimeSpan.FromTicks((long)(i * bucketWidthTicks)), sums[i])
                : best[i]);
        }

        return result;
    }
}
