import Foundation

/// The user's activity streak, with "streak freezes" folded in.
///
/// A streak is the current run of consecutive days with activity. For every
/// `freezeEveryDays` (5) days of the streak the user banks one *freeze*; a freeze is spent
/// automatically to bridge a single missed day so the run survives it. Misses are covered
/// consecutively, one freeze each, for as many as the user has banked — run out and the
/// streak breaks.
///
/// A frozen day is a missed day kept alive by a freeze. Like any missed day it does *not*
/// add to the streak count (the count is days you actually showed up); it only holds the run
/// together, and is surfaced distinctly (blue) in the activity grid.
///
/// Pure and platform-agnostic so the macOS host and the iPhone client compute identical
/// streaks from the same per-day activity.
public struct StreakState: Equatable, Sendable {
    /// Active days in the current run. Missed-but-frozen days don't count toward this.
    public let streak: Int
    /// Missed days the current streak spent a freeze to keep alive — rendered blue in the grid.
    public let frozenDays: Set<Date>
    /// Freezes still in reserve: one earned per `freezeEveryDays` days of the current streak,
    /// less those already spent bridging missed days.
    public let availableFreezes: Int

    public init(streak: Int, frozenDays: Set<Date>, availableFreezes: Int) {
        self.streak = streak
        self.frozenDays = frozenDays
        self.availableFreezes = availableFreezes
    }

    /// No streak, no freezes — the resting state.
    public static let empty = StreakState(streak: 0, frozenDays: [], availableFreezes: 0)

    /// One freeze is earned for every this-many days of streak.
    public static let freezeEveryDays = 5

    /// Compute the streak (freezes included) from per-day activity counts.
    ///
    /// Freezes can only be spent on a gap once they've been *earned*, and they're earned from
    /// the active days that precede the gap. Availability is therefore inherently a
    /// forward-in-time property, so we walk the calendar from the first active day toward
    /// today rather than backward from today.
    ///
    /// - Parameters:
    ///   - activityByDay: per-day activity tallies, keyed by `calendar.startOfDay` dates.
    ///   - today: the current moment. Today is still in progress, so a missed *today* neither
    ///            breaks the streak nor burns a freeze — it simply isn't counted yet.
    ///   - calendar: calendar used to normalize and step days (defaults to `.current`).
    ///   - freezeEveryDays: streak-days per earned freeze (defaults to ``freezeEveryDays``).
    public static func compute(
        activityByDay: [Date: Int],
        today: Date = Date(),
        calendar: Calendar = .current,
        freezeEveryDays: Int = StreakState.freezeEveryDays
    ) -> StreakState {
        func isActive(_ day: Date) -> Bool { (activityByDay[day] ?? 0) > 0 }

        let todayStart = calendar.startOfDay(for: today)
        // Earliest day with activity — where the forward walk begins. No activity at all ⇒ no streak.
        guard let firstActive = activityByDay
            .compactMap({ $0.value > 0 ? calendar.startOfDay(for: $0.key) : nil })
            .min()
        else { return .empty }

        // Walk day-by-day through *yesterday*, carrying the current run's active-day count, the
        // freezes it has spent, and which missed days a freeze bridged. A break resets all three
        // (banked freezes are forfeit with the streak); the next active day opens a fresh run.
        var activeCount = 0
        var freezesUsed = 0
        var frozen: [Date] = []
        var day = firstActive
        while day < todayStart {
            if isActive(day) {
                activeCount += 1
            } else if activeCount / freezeEveryDays - freezesUsed > 0 {
                // A banked freeze covers this missed day; the run lives on (but the day, being a
                // miss, doesn't add to the count).
                freezesUsed += 1
                frozen.append(day)
            } else {
                // Out of freezes — the streak breaks here.
                activeCount = 0
                freezesUsed = 0
                frozen.removeAll(keepingCapacity: true)
            }
            day = calendar.date(byAdding: .day, value: 1, to: day)!
        }
        // Today counts only once it has activity; a still-idle today is left untouched (no break,
        // no freeze spent) so a streak that ran through yesterday survives into the new day.
        if isActive(todayStart) { activeCount += 1 }

        guard activeCount > 0 else { return .empty }
        let available = activeCount / freezeEveryDays - freezesUsed
        return StreakState(
            streak: activeCount,
            frozenDays: Set(frozen),
            availableFreezes: max(0, available))
    }
}
