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

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

/// <summary>Tests for <see cref="KpiSeriesBucketer"/>.</summary>
public class KpiSeriesBucketerTests
{
    // Fixed epoch used throughout so tests are fully deterministic.
    private static readonly DateTime Epoch =
        new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc);

    private static DateTime T(int minutesFromEpoch) =>
        Epoch.AddMinutes(minutesFromEpoch);

    // -----------------------------------------------------------------------
    // Guard / invalid-argument contracts
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_MaxPointsLessThan2_ThrowsArgumentOutOfRangeException()
    {
        var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };

        Assert.Throws<ArgumentOutOfRangeException>(() =>
            KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 1));
    }

    [Fact]
    public void Bucket_MaxPointsZero_ThrowsArgumentOutOfRangeException()
    {
        var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };

        Assert.Throws<ArgumentOutOfRangeException>(() =>
            KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 0));
    }

    [Fact]
    public void Bucket_ToUtcEqualFromUtc_ThrowsArgumentOutOfRangeException()
    {
        var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };

        Assert.Throws<ArgumentOutOfRangeException>(() =>
            KpiSeriesBucketer.Bucket(raw, T(30), T(30), maxPoints: 5));
    }

    [Fact]
    public void Bucket_ToUtcBeforeFromUtc_ThrowsArgumentOutOfRangeException()
    {
        var raw = new[] { new KpiSeriesPoint(T(0), 1.0) };

        Assert.Throws<ArgumentOutOfRangeException>(() =>
            KpiSeriesBucketer.Bucket(raw, T(60), T(0), maxPoints: 5));
    }

    // -----------------------------------------------------------------------
    // Empty / null raw series
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_NullRaw_ReturnsEmptyList()
    {
        var result = KpiSeriesBucketer.Bucket(null!, T(0), T(60), maxPoints: 10);

        Assert.Empty(result);
    }

    [Fact]
    public void Bucket_EmptyRaw_ReturnsEmptyList()
    {
        var result = KpiSeriesBucketer.Bucket(
            Array.Empty<KpiSeriesPoint>(), T(0), T(60), maxPoints: 10);

        Assert.Empty(result);
    }

    // -----------------------------------------------------------------------
    // raw.Count <= maxPoints → returned unchanged (same reference)
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_RawCountEqualsMaxPoints_ReturnsSameReference()
    {
        var raw = new[]
        {
            new KpiSeriesPoint(T(0),  1.0),
            new KpiSeriesPoint(T(10), 2.0),
            new KpiSeriesPoint(T(20), 3.0),
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);

        Assert.Same(raw, result);
    }

    [Fact]
    public void Bucket_RawCountLessThanMaxPoints_ReturnsSameReference()
    {
        var raw = new[]
        {
            new KpiSeriesPoint(T(0), 5.0),
            new KpiSeriesPoint(T(5), 6.0),
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 10);

        Assert.Same(raw, result);
    }

    [Fact]
    public void Bucket_SinglePoint_ReturnsSameReference()
    {
        var raw = new[] { new KpiSeriesPoint(T(30), 42.0) };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5);

        Assert.Same(raw, result);
    }

    // -----------------------------------------------------------------------
    // Downsampling: correct bucket count and last-value selection
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_MorePointsThanMaxPoints_ReducesToMaxPointsBuckets()
    {
        // 60-minute window / 3 buckets → 20 min each.
        // Plant exactly one point in each bucket; all three buckets occupied.
        var raw = new[]
        {
            new KpiSeriesPoint(T(5),  10.0),  // bucket 0: [0, 20)
            new KpiSeriesPoint(T(25), 20.0),  // bucket 1: [20, 40)
            new KpiSeriesPoint(T(45), 30.0),  // bucket 2: [40, 60]
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);

        Assert.Equal(3, result.Count);
        Assert.Equal(10.0, result[0].Value);
        Assert.Equal(20.0, result[1].Value);
        Assert.Equal(30.0, result[2].Value);
    }

    [Fact]
    public void Bucket_MultiplePointsInSameBucket_LastValueWins()
    {
        // 60-minute window / 2 buckets → 30 min each.
        // Three points all land in bucket 0; the last (T(25)) should win.
        var raw = new[]
        {
            new KpiSeriesPoint(T(5),  1.0),
            new KpiSeriesPoint(T(15), 2.0),
            new KpiSeriesPoint(T(25), 99.0), // latest in bucket 0 → wins
            new KpiSeriesPoint(T(35), 5.0),  // bucket 1
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);

        Assert.Equal(2, result.Count);
        Assert.Equal(99.0, result[0].Value); // last in bucket 0
        Assert.Equal(5.0,  result[1].Value); // only point in bucket 1
    }

    [Fact]
    public void Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp()
    {
        // 60-minute window / 3 buckets → 20 min each.
        // Output BucketStartUtc must be the bucket boundary, not the raw point's time.
        // raw.Count (4) > maxPoints (3) ensures the downsampling path runs.
        var raw = new[]
        {
            new KpiSeriesPoint(T(7),  1.0), // inside bucket 0: [0, 20)
            new KpiSeriesPoint(T(22), 2.0), // inside bucket 1: [20, 40)
            new KpiSeriesPoint(T(35), 3.0), // inside bucket 1: later → wins bucket 1
            new KpiSeriesPoint(T(55), 4.0), // inside bucket 2: [40, 60]
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);

        // Bucket boundaries: 0 min, 20 min, 40 min
        Assert.Equal(3, result.Count);
        Assert.Equal(T(0),  result[0].BucketStartUtc);
        Assert.Equal(T(20), result[1].BucketStartUtc);
        Assert.Equal(T(40), result[2].BucketStartUtc);
    }

    // -----------------------------------------------------------------------
    // Unsorted input: last-in-iteration wins within a bucket (NOT largest timestamp)
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_UnsortedInput_SelectsLastInIterationNotLargestTimestamp()
    {
        // KpiHistory-006 regression: pins the documented contract that for unsorted input the
        // bucketer selects the LAST point in iteration order within a bucket — it does NOT pick
        // the largest-timestamp point. Both points below fall in bucket 0 ([0, 30)); the later
        // one in iteration order, T(5)=value 1.0, arrives second, so it overwrites T(20)=value
        // 99.0 even though T(20) has the larger timestamp. (For ascending-sorted input these
        // coincide — last-in-iteration IS largest timestamp — which is why production is safe.)
        var raw = new[]
        {
            new KpiSeriesPoint(T(20), 99.0), // larger timestamp, but encountered FIRST
            new KpiSeriesPoint(T(5),  1.0),  // smaller timestamp, but encountered LAST → wins
            new KpiSeriesPoint(T(45), 7.0),  // bucket 1 ([30, 60])
        };

        // 60-minute window / 2 buckets → 30 min each. raw.Count (3) > maxPoints (2) → downsample.
        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);

        Assert.Equal(2, result.Count);
        // Bucket 0: last-in-iteration (value 1.0) wins, NOT the largest-timestamp point (99.0).
        Assert.Equal(1.0, result[0].Value);
        Assert.Equal(7.0, result[1].Value);
    }

    // -----------------------------------------------------------------------
    // Short series (raw.Count <= maxPoints): returned unchanged → raw capture timestamps
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_ShortSeries_ReturnsRawCaptureTimestampsNotBucketBoundaries()
    {
        // KpiHistory-005/003 regression: the short-series early-return path returns the input
        // unchanged, so each output point's BucketStartUtc is the RAW capture timestamp — NOT a
        // bucket-boundary timestamp (which is what the downsample path emits, asserted by
        // Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp). This pins the
        // intentional difference between the two return paths.
        var raw = new[]
        {
            new KpiSeriesPoint(T(7),  1.0),
            new KpiSeriesPoint(T(23), 2.0),
        };

        // raw.Count (2) <= maxPoints (5) → early return, same reference.
        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5);

        Assert.Same(raw, result);
        // Timestamps are the raw capture instants, not bucket starts (which would be T(0), T(12), …).
        Assert.Equal(T(7),  result[0].BucketStartUtc);
        Assert.Equal(T(23), result[1].BucketStartUtc);
    }

    // -----------------------------------------------------------------------
    // Right-edge: point exactly at toUtc lands in the last bucket
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_PointAtToUtc_LandsInLastBucket()
    {
        // 60-minute window / 2 buckets → 30 min each.
        // A point exactly at T(60) = toUtc must go to bucket 1 (the last bucket),
        // not overflow to an out-of-range index.
        // T(10) → bucket 0: [0, 30); T(35) → bucket 1: [30, 60]; T(60) → bucket 1.
        var raw = new[]
        {
            new KpiSeriesPoint(T(10), 5.0),
            new KpiSeriesPoint(T(35), 6.0),
            new KpiSeriesPoint(T(60), 7.0), // exactly toUtc → must land in last bucket
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);

        Assert.Equal(2, result.Count);
        // Bucket 1 holds both T(35) and T(60); T(60) is later → wins.
        Assert.Equal(7.0, result[1].Value);
    }

    [Fact]
    public void Bucket_ThreeBucketWindow_PointAtToUtc_LandsInLastBucket()
    {
        // 60-minute window / 3 buckets → 20 min each.
        // A point exactly at T(60) = toUtc must land in bucket 2 (the last),
        // not overflow.  Use 4 raw points so downsampling is forced (4 > 3).
        // T(5) → bucket 0: [0,20); T(25) → bucket 1: [20,40);
        // T(45) → bucket 2: [40,60]; T(60) → bucket 2 (right edge, later → wins).
        var raw = new[]
        {
            new KpiSeriesPoint(T(5),  1.0),
            new KpiSeriesPoint(T(25), 2.0),
            new KpiSeriesPoint(T(45), 3.0),
            new KpiSeriesPoint(T(60), 99.0), // right edge — must land in last bucket
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3);

        Assert.Equal(3, result.Count);
        // Bucket 2 holds T(45)=3.0 and T(60)=99.0; T(60) is later → wins.
        Assert.Equal(99.0, result[2].Value);
    }

    // -----------------------------------------------------------------------
    // Empty buckets omitted — no gap-filling
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_GapInRaw_EmptyBucketsOmitted()
    {
        // 60-minute window / 4 buckets → 15 min each.
        // Populate only buckets 0 and 3; buckets 1 and 2 are empty.
        // Expect 2 output points, not 4.
        var raw = new[]
        {
            new KpiSeriesPoint(T(5),  10.0), // bucket 0: [0,  15)
            new KpiSeriesPoint(T(50), 20.0), // bucket 3: [45, 60]
        };

        // raw.Count (2) < maxPoints (4), so normally returns same reference.
        // To test the gap-omission path we need raw.Count > maxPoints and a gap.
        var raw2 = new[]
        {
            new KpiSeriesPoint(T(5),  10.0),
            new KpiSeriesPoint(T(6),  11.0),
            new KpiSeriesPoint(T(50), 20.0),
            new KpiSeriesPoint(T(51), 21.0),
        };

        // 4 raw points, maxPoints=4 → returns same reference (no downsampling).
        // Use maxPoints=3 to trigger the downsampler.
        // 60-minute window / 3 buckets → 20 min each.
        // T(5) → bucket 0, T(6) → bucket 0, T(50) → bucket 2, T(51) → bucket 2.
        // Bucket 1 is empty → 2 output points.
        var result = KpiSeriesBucketer.Bucket(raw2, T(0), T(60), maxPoints: 3);

        Assert.Equal(2, result.Count);
        Assert.Equal(11.0, result[0].Value); // last in bucket 0
        Assert.Equal(21.0, result[1].Value); // last in bucket 2
    }

    // -----------------------------------------------------------------------
    // Points outside [fromUtc, toUtc] are ignored
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_PointsOutsideWindow_AreIgnored()
    {
        // Window [T(10), T(50)], 2 buckets.
        // Points at T(0) and T(60) are outside — should not appear in output.
        var raw = new[]
        {
            new KpiSeriesPoint(T(0),  999.0), // before window
            new KpiSeriesPoint(T(20), 1.0),   // inside bucket 0: [T(10), T(30))
            new KpiSeriesPoint(T(40), 2.0),   // inside bucket 1: [T(30), T(50)]
            new KpiSeriesPoint(T(60), 999.0), // after window
        };

        var result = KpiSeriesBucketer.Bucket(raw, T(10), T(50), maxPoints: 2);

        Assert.Equal(2, result.Count);
        Assert.Equal(1.0, result[0].Value);
        Assert.Equal(2.0, result[1].Value);
    }

    // -----------------------------------------------------------------------
    // Two-point minimum — maxPoints == 2 works correctly
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_MaxPointsExactly2_ProducesAtMostTwoBuckets()
    {
        var raw = Enumerable
            .Range(0, 10)
            .Select(i => new KpiSeriesPoint(T(i * 6), (double)i))
            .ToArray();

        // 60-minute window / 2 buckets → 30 min each.
        var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);

        Assert.Equal(2, result.Count);
        // Bucket 0: T(0)–T(29) → last is T(24) (value 4); bucket 1: T(30)–T(60) → last is T(54) (value 9).
        Assert.Equal(4.0, result[0].Value);
        Assert.Equal(9.0, result[1].Value);
    }

    // -----------------------------------------------------------------------
    // Per-metric reduction: Rate series sum per bucket (arch-review 04 round 2, R3)
    // -----------------------------------------------------------------------

    [Fact]
    public void Bucket_RateSeries_SumsPerBucket_InsteadOfLastValue()
    {
        // 6 points of value 10 across a 60-min / 2-bucket window → each bucket = 30 (sum),
        // not 10 (last value). Three deltas per bucket; last-value would keep 1 of 3.
        var raw = new[]
        {
            new KpiSeriesPoint(T(5),  10.0), // bucket 0: [0, 30)
            new KpiSeriesPoint(T(15), 10.0), // bucket 0
            new KpiSeriesPoint(T(25), 10.0), // bucket 0
            new KpiSeriesPoint(T(35), 10.0), // bucket 1: [30, 60]
            new KpiSeriesPoint(T(45), 10.0), // bucket 1
            new KpiSeriesPoint(T(55), 10.0), // bucket 1
        };

        var result = KpiSeriesBucketer.Bucket(
            raw, T(0), T(60), maxPoints: 2, aggregation: KpiRollupAggregation.Rate);

        Assert.Equal(2, result.Count);
        Assert.Equal(30.0, result[0].Value);
        Assert.Equal(30.0, result[1].Value);
    }

    [Fact]
    public void Bucket_RateSeries_ShortSeries_StillSumsClusteredPoints()
    {
        // 3 points (< maxPoints 5) with two sharing a bucket → the shared bucket is their SUM.
        // Rate series skip the raw.Count <= maxPoints early return so totals stay truthful.
        // 60-min / 5-bucket window → 12 min each. T(2),T(5) → bucket 0 [0,12); T(30) → bucket 2 [24,36).
        var raw = new[]
        {
            new KpiSeriesPoint(T(2),  10.0),
            new KpiSeriesPoint(T(5),  10.0),
            new KpiSeriesPoint(T(30), 10.0),
        };

        var result = KpiSeriesBucketer.Bucket(
            raw, T(0), T(60), maxPoints: 5, aggregation: KpiRollupAggregation.Rate);

        Assert.Equal(2, result.Count);
        Assert.Equal(20.0, result[0].Value); // T(2)+T(5) summed
        Assert.Equal(10.0, result[1].Value); // T(30) alone
    }

    [Fact]
    public void Bucket_RateSeries_PreservesSeriesTotal()
    {
        // Strong invariant: sum(output values) == sum(in-window input values) for Rate.
        var raw = Enumerable
            .Range(0, 20)
            .Select(i => new KpiSeriesPoint(T(i * 3), (double)(i + 1)))
            .ToArray();

        var result = KpiSeriesBucketer.Bucket(
            raw, T(0), T(60), maxPoints: 4, aggregation: KpiRollupAggregation.Rate);

        var inWindowTotal = raw
            .Where(p => p.BucketStartUtc >= T(0) && p.BucketStartUtc <= T(60))
            .Sum(p => p.Value);
        Assert.Equal(inWindowTotal, result.Sum(p => p.Value));
    }

    [Fact]
    public void Bucket_DefaultAggregation_IsGauge_AndBehaviorUnchanged()
    {
        // Calling the existing 4-arg shape produces identical output to the explicit Gauge call.
        var raw = new[]
        {
            new KpiSeriesPoint(T(5),  1.0),
            new KpiSeriesPoint(T(15), 2.0),
            new KpiSeriesPoint(T(25), 99.0),
            new KpiSeriesPoint(T(35), 5.0),
        };

        var fourArg = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2);
        var explicitGauge = KpiSeriesBucketer.Bucket(
            raw, T(0), T(60), maxPoints: 2, aggregation: KpiRollupAggregation.Gauge);

        Assert.Equal(fourArg.Count, explicitGauge.Count);
        for (int i = 0; i < fourArg.Count; i++)
        {
            Assert.Equal(fourArg[i].BucketStartUtc, explicitGauge[i].BucketStartUtc);
            Assert.Equal(fourArg[i].Value, explicitGauge[i].Value);
        }
        // And the last-value semantics are unchanged.
        Assert.Equal(99.0, fourArg[0].Value);
        Assert.Equal(5.0, fourArg[1].Value);
    }
}
