import Foundation
import Testing

@testable import NucleicProtocol

@Suite struct StreakStateTests {
    /// A fixed UTC calendar + reference "today" so day math is deterministic (no DST/zone drift).
    private let cal: Calendar = {
        var c = Calendar(identifier: .gregorian)
        c.timeZone = TimeZone(identifier: "UTC")!
        return c
    }()
    private var today: Date { cal.date(from: DateComponents(year: 2026, month: 6, day: 26, hour: 9))! }
    /// Day `offset` from the start of today (0 = today, −1 = yesterday, …).
    private func day(_ offset: Int) -> Date {
        cal.date(byAdding: .day, value: offset, to: cal.startOfDay(for: today))!
    }
    /// Build an activity map from day offsets (each given day gets one message).
    private func activity(_ offsets: [Int]) -> [Date: Int] {
        Dictionary(uniqueKeysWithValues: offsets.map { (day($0), 1) })
    }
    private func compute(_ offsets: [Int]) -> StreakState {
        StreakState.compute(activityByDay: activity(offsets), today: today, calendar: cal)
    }

    @Test func noActivityIsEmpty() {
        #expect(compute([]) == .empty)
    }

    @Test func consecutiveActiveDaysCount() {
        let s = compute([-2, -1, 0])
        #expect(s.streak == 3)
        #expect(s.frozenDays.isEmpty)
        #expect(s.availableFreezes == 0)  // 3 / 5 = 0 earned
    }

    @Test func idleTodayKeepsStreakThroughYesterday() {
        // Active through yesterday, nothing yet today: the streak holds (today's still in progress).
        let s = compute([-3, -2, -1])
        #expect(s.streak == 3)
        #expect(s.frozenDays.isEmpty)
    }

    @Test func gapWithNoFreezeBreaksStreak() {
        // Only 3 active days before a missed day → no freeze earned → the run can't bridge it.
        let s = StreakState.compute(activityByDay: activity([-5, -4, -3, /* -2 missed */ -1, 0]),
                                    today: today, calendar: cal)
        #expect(s.streak == 2)               // only the -1, 0 run survives
        #expect(s.frozenDays.isEmpty)
    }

    @Test func earnsOneFreezePerFiveDays() {
        #expect(compute([-4, -3, -2, -1, 0]).availableFreezes == 1)                       // 5 days
        #expect(compute([-9, -8, -7, -6, -5, -4, -3, -2, -1, 0]).availableFreezes == 2)   // 10 days
    }

    @Test func freezeBridgesOneMissedDay() {
        // 5 active (earns 1), one missed day (bridged), then active again today.
        let s = compute([-6, -5, -4, -3, -2, /* -1 missed */ 0])
        #expect(s.streak == 6)                    // 6 active days; the frozen day isn't counted
        #expect(s.frozenDays == [day(-1)])
        #expect(s.availableFreezes == 0)          // 6/5 = 1 earned, 1 spent
    }

    @Test func missedYesterdayBridgedEvenWhenTodayIdle() {
        // 5 active days ending two days ago (earns 1 freeze); yesterday is missed and today is
        // still idle. The freeze bridges yesterday, so the streak holds at 5 pending today.
        let s = compute([-6, -5, -4, -3, -2 /* yesterday (-1) missed, today (0) idle */])
        #expect(s.streak == 5)
        #expect(s.frozenDays == [day(-1)])
        #expect(s.availableFreezes == 0)
    }

    @Test func consecutiveFreezesSpendMultipleBanked() {
        // 10 active (earns 2), two consecutive missed days (both bridged), then active today.
        let s = compute([-12, -11, -10, -9, -8, -7, -6, -5, -4, -3, /* -2,-1 missed */ 0])
        #expect(s.streak == 11)
        #expect(s.frozenDays == [day(-2), day(-1)])
        #expect(s.availableFreezes == 0)          // 11/5 = 2 earned, 2 spent
    }

    @Test func streakBreaksWhenFreezesExhausted() {
        // 5 active (earns 1) then TWO consecutive missed days: the first is bridged, the second
        // exhausts freezes and breaks the run. A lone active day today starts fresh.
        let s = compute([-7, -6, -5, -4, -3, /* -2 missed (frozen), -1 missed (break) */ 0])
        #expect(s.streak == 1)                    // only today survives
        #expect(s.frozenDays.isEmpty)             // the pre-break frozen day is discarded with the broken run
        #expect(s.availableFreezes == 0)
    }

    @Test func longActiveRunBanksFreezesForLaterGap() {
        // 10 active, then a gap mid-run, then more active days through today: the gap is bridged
        // and the run continues unbroken.
        let active = Array(-13 ... -4)            // 10 days
        let s = StreakState.compute(
            activityByDay: activity(active + [-2, -1, 0]),   // -3 is the missed/frozen day
            today: today, calendar: cal)
        #expect(s.frozenDays == [day(-3)])
        #expect(s.streak == 13)                   // 13 active days, frozen day not counted
    }
}
