import Foundation

/// Maps a day's activity count to a display *brightness* relative to the user's own
/// activity, so the activity grid's brightest squares mean "a heavy day **for you**"
/// rather than crossing some fixed global threshold.
///
/// The catch with naive max-scaling is outliers: a single marathon day (say 200 messages
/// against a usual 5–10) would pin the top of the scale so high that every ordinary day
/// collapses to near-black, crushing the range you actually want to read. So the scale's
/// top is a *robust* upper bound — the busiest day that still falls within Tukey's upper
/// fence (`Q3 + 1.5·IQR`). Outliers past the fence simply clamp to full brightness; the
/// everyday days span the rest of the range at full contrast.
///
/// Pure and platform-agnostic so the macOS host and the iPhone client shade their grids
/// identically from the same per-day activity (mirrors ``StreakState``).
public struct ActivityScale: Sendable, Equatable {
    /// The activity count mapped to full brightness. Days at or above it clamp to the top.
    /// Always ≥ 1 so a lone active day still renders at full strength.
    public let upperBound: Int
    /// Lightest brightness given to an *active* day, so even a one-message day stays visible
    /// and clearly distinct from an empty day. Active days span `floor…1`.
    public let floor: Double

    /// Default floor: matches the historical lightest "level 1" activity shade (opacity 0.4),
    /// so a minimal active day looks unchanged from the old fixed-threshold grid.
    public static let defaultFloor = 0.4

    public init(upperBound: Int, floor: Double = ActivityScale.defaultFloor) {
        self.upperBound = max(1, upperBound)
        self.floor = floor
    }

    /// Build a scale from per-day activity, excluding high outliers from the upper bound.
    ///
    /// Only active days (count > 0) inform the scale — empty days carry no signal. With no
    /// activity at all the scale is inert (`upperBound == 1`, every day maps to 0).
    public init(activityByDay: [Date: Int], floor: Double = ActivityScale.defaultFloor) {
        let counts = activityByDay.values.filter { $0 > 0 }.sorted()
        guard let maxCount = counts.last else {
            self.init(upperBound: 1, floor: floor)
            return
        }
        // Tukey's upper fence over the active days. Anything beyond it is an outlier.
        let q1 = ActivityScale.quantile(counts, 0.25)
        let q3 = ActivityScale.quantile(counts, 0.75)
        let fence = q3 + 1.5 * (q3 - q1)
        // Cap the scale at the busiest *non-outlier* day so a few marathon days don't flatten
        // the everyday range; with no outliers this is simply the max.
        let bound = counts.last(where: { Double($0) <= fence }) ?? maxCount
        self.init(upperBound: bound, floor: floor)
    }

    /// Continuous brightness in `0…1` for a day's `count`, relative to the scale.
    ///
    /// An empty day is `0` (renders as the neutral empty-day gray). Active days are lifted to
    /// at least ``floor`` and grow linearly to `1` at ``upperBound``; outliers above the bound
    /// clamp to `1`.
    public func intensity(for count: Int) -> Double {
        guard count > 0 else { return 0 }
        let t = min(1.0, Double(count) / Double(upperBound))
        return floor + (1 - floor) * t
    }

    /// Type-7 (linear-interpolation) quantile of an ascending, non-empty array — the same
    /// method NumPy/R use by default, so the fence math is unsurprising.
    static func quantile(_ sorted: [Int], _ q: Double) -> Double {
        guard let first = sorted.first else { return 0 }
        if sorted.count == 1 { return Double(first) }
        let pos = q * Double(sorted.count - 1)
        let lo = Int(pos.rounded(.down))
        let hi = Int(pos.rounded(.up))
        let frac = pos - Double(lo)
        return Double(sorted[lo]) * (1 - frac) + Double(sorted[hi]) * frac
    }
}
