import Foundation
import Testing

@testable import NucleicProtocol

@Suite struct ActivityScaleTests {
    private let cal: Calendar = {
        var c = Calendar(identifier: .gregorian)
        c.timeZone = TimeZone(identifier: "UTC")!
        return c
    }()
    private let epoch = Date(timeIntervalSince1970: 0)
    /// Build an activity map from a list of per-day counts (each on its own day).
    private func activity(_ counts: [Int]) -> [Date: Int] {
        Dictionary(uniqueKeysWithValues: counts.enumerated().map { i, n in
            (cal.date(byAdding: .day, value: i, to: epoch)!, n)
        })
    }

    @Test func noActivityIsInert() {
        let scale = ActivityScale(activityByDay: [:])
        #expect(scale.upperBound == 1)
        #expect(scale.intensity(for: 0) == 0)
        // A count can't really occur with no data, but the scale must stay well-defined.
        #expect(scale.intensity(for: 5) == 1.0)
    }

    @Test func emptyDayHasZeroBrightness() {
        let scale = ActivityScale(activityByDay: activity([1, 2, 3]))
        #expect(scale.intensity(for: 0) == 0)
    }

    @Test func activeDayIsLiftedToFloor() {
        // The lightest active day sits at the floor, never near-black, so it stays visible.
        let scale = ActivityScale(activityByDay: activity([1, 100, 100, 100]))
        #expect(scale.intensity(for: 1) >= ActivityScale.defaultFloor)
        #expect(scale.intensity(for: 1) > scale.intensity(for: 0))
    }

    @Test func brightnessIsMonotonicInActivity() {
        let scale = ActivityScale(activityByDay: activity([1, 2, 3, 4, 5, 6, 7, 8]))
        let ramp = (0...8).map { scale.intensity(for: $0) }
        for (lo, hi) in zip(ramp, ramp.dropFirst()) { #expect(hi >= lo) }
    }

    @Test func relativeToOwnDistribution() {
        // Same absolute count reads very differently against a light vs a heavy user.
        let light = ActivityScale(activityByDay: activity([1, 2, 3, 4]))   // busiest normal day = 4
        let heavy = ActivityScale(activityByDay: activity([20, 40, 60, 80]))
        #expect(light.upperBound == 4)
        #expect(heavy.upperBound == 80)
        // 4 messages is a peak day for the light user, a quiet day for the heavy one.
        #expect(light.intensity(for: 4) == 1.0)
        #expect(heavy.intensity(for: 4) < 0.5)
    }

    @Test func linearBetweenFloorAndFull() {
        let scale = ActivityScale(activityByDay: activity([1, 2, 3, 4]))   // upperBound 4
        let floor = ActivityScale.defaultFloor
        #expect(abs(scale.intensity(for: 2) - (floor + (1 - floor) * 0.5)) < 1e-9)
        #expect(abs(scale.intensity(for: 4) - 1.0) < 1e-9)
    }

    @Test func outliersDoNotCrushTheStandardRange() {
        // Seven ordinary days (2…8) plus one 200-message marathon. The fence is Q3 + 1.5·IQR
        // (= 12.5 here), so the scale tops out at the busiest *non-outlier* day (8) — the
        // marathon clamps to full instead of dragging every normal day toward black.
        let scale = ActivityScale(activityByDay: activity([2, 3, 4, 5, 6, 7, 8, 200]))
        #expect(scale.upperBound == 8)
        #expect(scale.intensity(for: 8) == 1.0)       // busiest normal day = full brightness
        #expect(scale.intensity(for: 200) == 1.0)     // outlier clamps to the top, doesn't exceed it

        // Contrast: naive max-scaling (top = 200) would crush the busiest normal day toward the floor.
        let naive = ActivityScale(upperBound: 200)
        #expect(naive.intensity(for: 8) < 0.5)
        #expect(scale.intensity(for: 8) > naive.intensity(for: 8) + 0.4)
    }

    @Test func noOutliersUsesTheMax() {
        // A tidy distribution has no Tukey outliers, so the bound is just the max.
        let scale = ActivityScale(activityByDay: activity([1, 2, 3, 4]))
        #expect(scale.upperBound == 4)
    }

    @Test func uniformActivityIsUniformlyBright() {
        // Every day identical ⇒ IQR 0, fence == that count ⇒ all days full brightness.
        let scale = ActivityScale(activityByDay: activity([7, 7, 7, 7, 7]))
        #expect(scale.upperBound == 7)
        #expect(scale.intensity(for: 7) == 1.0)
    }

    @Test func singleActiveDayIsFullBrightness() {
        let scale = ActivityScale(activityByDay: activity([5]))
        #expect(scale.upperBound == 5)
        #expect(scale.intensity(for: 5) == 1.0)
    }

    @Test func quantileMatchesType7() {
        // Sanity-check the quantile helper against known values (NumPy/R default, type 7).
        let xs = [1, 2, 3, 4]
        #expect(abs(ActivityScale.quantile(xs, 0.25) - 1.75) < 1e-9)
        #expect(abs(ActivityScale.quantile(xs, 0.75) - 3.25) < 1e-9)
        #expect(ActivityScale.quantile([9], 0.5) == 9)
    }
}
