import Foundation
import Testing
@testable import Workouts

/// Locks the derivation rules of `ProgressPlanner` — the Progress tab's pure adherence,
/// streak, trend, and achievement engine (precedent: `SeedReconcilePlanner`). Every
/// assertion below is a deterministic function of plain value snapshots (`ScheduleFacts`,
/// `WorkoutFacts`, bare `WeekMark`/`GoalTrack`) run through a fixed UTC, Monday-first
/// calendar, so nothing here depends on the host machine's clock, locale, or timezone.
struct ProgressPlannerTests {

    // MARK: - Fixed calendar & date helpers

    /// UTC Gregorian, Monday-first weeks — matches the app's real `weekOfYear` grid
    /// without depending on the host machine's locale or timezone.
    private let cal: Calendar = {
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = TimeZone(identifier: "UTC")!
        cal.firstWeekday = 2
        return cal
    }()

    /// Midnight UTC on an ISO `yyyy-MM-dd` date. All the fixture dates below live in
    /// June/July 2026, whose weekday layout (verified against two independent anchors)
    /// is: 2026-06-29 is a Monday, so the two relevant Monday–Sunday weeks are
    /// 06-29…07-05 and 07-06…07-12.
    private func day(_ iso: String) -> Date {
        let parts = iso.split(separator: "-").compactMap { Int($0) }
        var components = DateComponents()
        components.year = parts[0]; components.month = parts[1]; components.day = parts[2]
        return cal.date(from: components)!
    }

    /// The same calendar day at a specific hour, for time-of-day achievements.
    private func at(_ base: Date, hour: Int) -> Date {
        cal.date(bySettingHour: hour, minute: 0, second: 0, of: base)!
    }

    // MARK: - Fixtures

    private func schedule(
        id: String = "SCH-1",
        goal: GoalKind? = .strength,
        recurrence: ScheduleRecurrence,
        weekdays: [Int]? = nil,
        onceDate: Date? = nil,
        createdAt: Date,
        routineID: String = "R-1",
        routineName: String = "Push Day"
    ) -> ScheduleFacts {
        ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays,
                      onceDate: onceDate, createdAt: createdAt,
                      routineID: routineID, routineName: routineName)
    }

    private func workout(
        routineID: String? = "R-1",
        start: Date,
        end: Date? = nil,
        completed: Bool = true,
        volume: Double = 0,
        activeMinutes: Double = 0,
        energyKcal: Double = 0
    ) -> WorkoutFacts {
        WorkoutFacts(routineID: routineID, start: start, end: end, completed: completed,
                     volume: volume, activeMinutes: activeMinutes, energyKcal: energyKcal)
    }

    /// A bare `WeekMark` for exercising `streak`/`bestHitRun` directly — their logic
    /// only reads `met`/`expected`, never `weekStart`, so a fixed date is fine.
    private func mark(_ met: Int, _ expected: Int) -> WeekMark {
        WeekMark(weekStart: day("2026-01-01"), met: met, expected: expected)
    }

    /// A bare `GoalTrack` for exercising `cleanSweepExists` (reached via `achievements`)
    /// directly, without routing through `goalTracks`.
    private func goalTrack(_ goal: GoalKind?, _ weeks: [WeekMark]) -> GoalTrack {
        GoalTrack(goal: goal, scheduleTracks: [], weeks: weeks, streakWeeks: 0, streakAlive: true)
    }

    // MARK: - weekMark: daily

    @Test func weekMarkDaily() {
        let daily = schedule(recurrence: .daily, createdAt: day("2026-06-01"))
        let now = day("2026-07-08") // Wednesday; current week is Mon 07-06 – Sun 07-12

        // A fully-past week (Mon 06-29 – Sun 07-05), every day done.
        let pastWeek = day("2026-06-29")
        let fullyMetDays = Set((0..<7).map { cal.date(byAdding: .day, value: $0, to: pastWeek)! })
        let fullyMet = ProgressPlanner.weekMark(schedule: daily, doneDays: fullyMetDays,
                                                 weekStart: pastWeek, calendar: cal, now: now)
        #expect(fullyMet?.met == 7)
        #expect(fullyMet?.expected == 7)
        #expect(fullyMet?.hit == true)

        // Same past week, only 4 of 7 days done — a clean met/expected count, not hit.
        let partialDays = Set([0, 1, 3, 5].map { cal.date(byAdding: .day, value: $0, to: pastWeek)! })
        let partial = ProgressPlanner.weekMark(schedule: daily, doneDays: partialDays,
                                                weekStart: pastWeek, calendar: cal, now: now)
        #expect(partial?.met == 4)
        #expect(partial?.expected == 7)
        #expect(partial?.hit == false)

        // The current week (Mon 07-06 – Sun 07-12): only Mon/Tue/Wed have elapsed by
        // `now`, so expected clamps to 3, not the full 7 — and a "done" day beyond
        // today wouldn't count even if present in doneDays.
        let currentWeek = day("2026-07-06")
        let doneSoFar: Set<Date> = [day("2026-07-06"), day("2026-07-07")]
        let current = ProgressPlanner.weekMark(schedule: daily, doneDays: doneSoFar,
                                                weekStart: currentWeek, calendar: cal, now: now)
        #expect(current?.expected == 3)
        #expect(current?.met == 2)
    }

    // MARK: - weekMark: fixedDays

    @Test func weekMarkFixedDays() {
        // Required on Mon/Wed/Fri (weekday numbers 2, 4, 6).
        let fixed = schedule(recurrence: .fixedDays, weekdays: [2, 4, 6], createdAt: day("2026-06-01"))
        let now = day("2026-07-08") // Wednesday

        // Met exactly on the fixed days themselves.
        let pastWeek = day("2026-06-29")
        let onFixedDays: Set<Date> = [day("2026-06-29"), day("2026-07-01"), day("2026-07-03")]
        let metOnFixed = ProgressPlanner.weekMark(schedule: fixed, doneDays: onFixedDays,
                                                   weekStart: pastWeek, calendar: cal, now: now)
        #expect(metOnFixed?.met == 3)
        #expect(metOnFixed?.expected == 3)
        #expect(metOnFixed?.hit == true)

        // A catch-up on a non-fixed day (Tuesday) still counts toward met: Monday
        // done + a Tuesday catch-up reaches met == 2 even though Wednesday and Friday
        // were both skipped — the requirement is 3, so it still isn't hit, but the
        // catch-up visibly moved met from what a fixed-days-only count (1) would give.
        let withCatchUp: Set<Date> = [day("2026-06-29"), day("2026-06-30")]
        let catchUp = ProgressPlanner.weekMark(schedule: fixed, doneDays: withCatchUp,
                                                weekStart: pastWeek, calendar: cal, now: now)
        #expect(catchUp?.met == 2)
        #expect(catchUp?.expected == 3)
        #expect(catchUp?.hit == false)

        // The current week (Mon 07-06 – Sun 07-12): expected counts only the fixed
        // days that have already elapsed (Mon + Wed), not the not-yet-arrived Friday.
        let currentWeek = day("2026-07-06")
        let current = ProgressPlanner.weekMark(schedule: fixed, doneDays: [day("2026-07-06"), day("2026-07-08")],
                                                weekStart: currentWeek, calendar: cal, now: now)
        #expect(current?.expected == 2)
        #expect(current?.met == 2)
    }

    // MARK: - weekMark: once

    @Test func weekMarkOnce() {
        let onceDate = day("2026-07-08") // Wednesday, in week Mon 07-06 – Sun 07-12
        let once = schedule(recurrence: .once, onceDate: onceDate, createdAt: day("2026-06-01"))
        let now = day("2026-07-10")

        // A week that doesn't contain the once date: nil, whether before or after it.
        #expect(ProgressPlanner.weekMark(schedule: once, doneDays: [], weekStart: day("2026-06-29"),
                                          calendar: cal, now: now) == nil)
        #expect(ProgressPlanner.weekMark(schedule: once, doneDays: [], weekStart: day("2026-07-13"),
                                          calendar: cal, now: now) == nil)

        // Its own week: expected is always 1.
        let ownWeek = day("2026-07-06")
        let unmet = ProgressPlanner.weekMark(schedule: once, doneDays: [], weekStart: ownWeek,
                                              calendar: cal, now: now)
        #expect(unmet?.expected == 1)
        #expect(unmet?.met == 0)

        // Met when completed on ANY day that week — not necessarily the once date itself.
        let anyDayDone: Set<Date> = [day("2026-07-07")]
        let met = ProgressPlanner.weekMark(schedule: once, doneDays: anyDayDone, weekStart: ownWeek,
                                            calendar: cal, now: now)
        #expect(met?.met == 1)
        #expect(met?.hit == true)
    }

    // MARK: - weekMark: creation-window edges

    @Test func weekMarkNilBeforeCreationAndClipsCreationWeek() {
        let createdAt = day("2026-07-08") // Wednesday
        let daily = schedule(recurrence: .daily, createdAt: createdAt)
        let now = day("2026-07-12")

        // A week entirely before creation: nil, not a zeroed mark.
        #expect(ProgressPlanner.weekMark(schedule: daily, doneDays: [], weekStart: day("2026-06-29"),
                                          calendar: cal, now: now) == nil)

        // The creation week itself: expected only counts the days since creation
        // (Wed–Sun = 5), not the full 7-day week.
        let creationWeek = day("2026-07-06")
        let mark = ProgressPlanner.weekMark(schedule: daily, doneDays: [], weekStart: creationWeek,
                                             calendar: cal, now: now)
        #expect(mark?.expected == 5)
    }

    // MARK: - currentWeekBroken

    @Test func currentWeekBrokenDailyMissedYesterdayVsUndoneToday() {
        let daily = schedule(recurrence: .daily, createdAt: day("2026-06-01"))
        let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12
        let weekStart = day("2026-07-06")

        // Missed Tuesday: even finishing every remaining day (Wed–Sun, 5 days) can't
        // reach the full 7 — the week is already unmeetable.
        let missedYesterday: Set<Date> = [day("2026-07-06")]
        #expect(ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: missedYesterday,
                                                    weekStart: weekStart, calendar: cal, now: now))

        // Monday and Tuesday both done; today (Wednesday) simply hasn't happened yet —
        // still fully recoverable, not broken.
        let undoneToday: Set<Date> = [day("2026-07-06"), day("2026-07-07")]
        #expect(!ProgressPlanner.currentWeekBroken(schedule: daily, doneDays: undoneToday,
                                                     weekStart: weekStart, calendar: cal, now: now))
    }

    @Test func currentWeekBrokenFixedDaysCatchUpDaysRemainingNotBroken() {
        // Mon/Wed/Fri required. Monday (a fixed day) was missed, but it's only
        // Tuesday — catch-up days (Wed, Fri, or any other day this week) still remain.
        let fixed = schedule(recurrence: .fixedDays, weekdays: [2, 4, 6], createdAt: day("2026-06-01"))
        #expect(!ProgressPlanner.currentWeekBroken(schedule: fixed, doneDays: [],
                                                     weekStart: day("2026-07-06"), calendar: cal,
                                                     now: day("2026-07-07")))
    }

    // MARK: - streak

    @Test func streakCountsConsecutiveHitWeeksNewestToOldest() {
        // Three consecutive hits ending at the newest (current) week.
        #expect(ProgressPlanner.streak(weeks: [mark(1, 1), mark(1, 1), mark(1, 1)], alive: true) == 3)

        // A miss two weeks back stops the run at the newest unbroken stretch.
        #expect(ProgressPlanner.streak(weeks: [mark(0, 1), mark(1, 1), mark(1, 1)], alive: true) == 2)
    }

    @Test func streakCurrentWeekUnhitButAliveNeitherCountsNorBreaks() {
        // The current (last) week isn't hit yet but is still meetable ("alive") — it
        // neither adds to the count nor zeroes it; the streak reads from the prior
        // unbroken run.
        let weeks = [mark(1, 1), mark(1, 1), mark(0, 1)]
        #expect(ProgressPlanner.streak(weeks: weeks, alive: true) == 2)
    }

    @Test func streakCurrentWeekUnhitAndDeadReturnsZero() {
        // Same shape, but the current week is already unmeetable ("dead") — the whole
        // streak breaks, even though the two prior weeks were hits.
        let weeks = [mark(1, 1), mark(1, 1), mark(0, 1)]
        #expect(ProgressPlanner.streak(weeks: weeks, alive: false) == 0)
    }

    @Test func streakVacuousWeeksAreTransparent() {
        // A vacuous (expected 0) week between two hits neither breaks the run nor
        // resets it — it's invisible to the scan, so the hits on either side bridge
        // across it into one streak.
        let weeks = [mark(1, 1), mark(0, 0), mark(1, 1)]
        #expect(ProgressPlanner.streak(weeks: weeks, alive: true) == 2)

        // Alive is irrelevant when the current week is itself vacuous.
        let vacuousCurrent = [mark(1, 1), mark(1, 1), mark(0, 0)]
        #expect(ProgressPlanner.streak(weeks: vacuousCurrent, alive: false) == 2)
    }

    // MARK: - bestHitRun & longestDayRun

    @Test func bestHitRunFindsLongestRunAroundAMissWithVacuousWeeksTransparent() {
        // hit, miss, hit, hit, vacuous, hit, hit — the miss resets the run, but the
        // vacuous week doesn't, so the trailing run bridges to length 4.
        let weeks = [mark(1, 1), mark(0, 1), mark(1, 1), mark(1, 1), mark(0, 0), mark(1, 1), mark(1, 1)]
        #expect(ProgressPlanner.bestHitRun(weeks: weeks) == 4)
    }

    @Test func longestDayRunCountsConsecutiveDaysAcrossAGap() {
        let workouts = [
            workout(start: day("2026-07-01")),
            workout(start: day("2026-07-02")),
            workout(start: day("2026-07-03")),
            workout(start: day("2026-07-04"), completed: false), // doesn't bridge the gap
            workout(start: day("2026-07-06")),
            workout(start: day("2026-07-07")),
        ]
        #expect(ProgressPlanner.longestDayRun(workouts: workouts, calendar: cal) == 3)
    }

    // MARK: - goalTracks

    @Test func goalTracksGroupsByGoalOrderWithTrailingNilGroupAndSkipsEmptyGoals() {
        let now = day("2026-07-08")
        let strength1 = schedule(id: "S1", goal: .strength, recurrence: .daily, createdAt: day("2026-06-01"))
        let strength2 = schedule(id: "S2", goal: .strength, recurrence: .daily, createdAt: day("2026-06-01"))
        let unassigned = schedule(id: "S3", goal: nil, recurrence: .daily, createdAt: day("2026-06-01"))

        let tracks = ProgressPlanner.goalTracks(
            schedules: [strength1, strength2, unassigned], workouts: [], weeksBack: 1,
            goalOrder: [.strength, .mobility, .cardio, .mindfulness], calendar: cal, now: now)

        // Only goals with member schedules produce a track (mobility/cardio/mindfulness
        // are skipped entirely, not emitted empty); the goal-less group trails last.
        #expect(tracks.map(\.goal) == [.strength, nil])
        #expect(tracks[0].scheduleTracks.map(\.schedule.id) == ["S1", "S2"])
    }

    @Test func goalTracksRollupSumsAndStreakAliveFollowsBrokenMember() throws {
        let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12
        let onTrack = schedule(id: "S1", goal: .strength, recurrence: .daily,
                                createdAt: day("2026-06-01"), routineID: "R-ok")
        let broken = schedule(id: "S2", goal: .strength, recurrence: .daily,
                               createdAt: day("2026-06-01"), routineID: "R-broken")

        let onTrackWorkouts = [day("2026-07-06"), day("2026-07-07"), day("2026-07-08")]
            .map { workout(routineID: "R-ok", start: $0) }
        // Missed Tuesday permanently — even finishing every remaining day can't reach 7/7.
        let brokenWorkouts = [workout(routineID: "R-broken", start: day("2026-07-06"))]

        let tracks = ProgressPlanner.goalTracks(
            schedules: [onTrack, broken], workouts: onTrackWorkouts + brokenWorkouts, weeksBack: 1,
            calendar: cal, now: now)

        let strength = try #require(tracks.first { $0.goal == .strength })
        // Rollup sums met/expected across BOTH member schedules for the same week.
        #expect(strength.weeks.last?.met == 4)       // 3 (onTrack) + 1 (broken)
        #expect(strength.weeks.last?.expected == 6)  // 3 (onTrack) + 3 (broken, elapsed so far)
        // One member's current week is already unmeetable, so the goal's streak is dead.
        #expect(strength.streakAlive == false)
    }

    // MARK: - trendPoints

    @Test func trendPointsFiltersCompletedOnlyAndZeroFillsDailyBucketsForWeek() {
        let now = day("2026-07-08") // cutoff = now - 7 days = 07-01
        let workouts = [
            workout(start: day("2026-07-02"), completed: true, volume: 10),
            workout(start: day("2026-07-02"), completed: true, volume: 5),    // same-day sum
            workout(start: day("2026-07-03"), completed: false, volume: 999), // excluded entirely
            workout(start: day("2026-07-05"), completed: true, volume: 20),
        ]
        let points = ProgressPlanner.trendPoints(workouts: workouts, range: .week, calendar: cal, now: now)

        // One point per day from the cutoff (07-01) through now (07-08) inclusive.
        #expect(points.count == 8)
        #expect(points.first?.date == day("2026-07-01"))
        #expect(points.last?.date == day("2026-07-08"))

        func point(_ iso: String) -> TrendPoint? { points.first { $0.date == day(iso) } }
        #expect(point("2026-07-02")?.workouts == 2)
        #expect(point("2026-07-02")?.volume == 15)
        // The gap day had an incomplete workout, but it's filtered out — zero-filled,
        // not dropped and not counted.
        #expect(point("2026-07-03")?.workouts == 0)
        #expect(point("2026-07-04")?.workouts == 0)
        #expect(point("2026-07-05")?.workouts == 1)
        #expect(point("2026-07-05")?.volume == 20)
    }

    @Test func trendPointsZeroFillsEntireWindowEvenWithNoWorkouts() {
        // Bounded ranges (a non-nil cutoff) zero-fill the whole window regardless of
        // data — only the unbounded `.all` range can produce an empty array.
        let points = ProgressPlanner.trendPoints(workouts: [], range: .week, calendar: cal, now: day("2026-07-08"))
        #expect(points.count == 8)
        #expect(points.allSatisfy { $0.workouts == 0 })
    }

    @Test func trendPointsWeeklyBucketsForQuarter() {
        let now = day("2026-07-08") // Wednesday, week of Mon 07-06
        let workouts = [
            workout(start: day("2026-07-06")),  // same week as 07-08
            workout(start: day("2026-07-08")),
            workout(start: day("2026-06-22")),  // an earlier week
        ]
        let points = ProgressPlanner.trendPoints(workouts: workouts, range: .quarter, calendar: cal, now: now)

        let thisWeek = points.first { $0.date == day("2026-07-06") }
        #expect(thisWeek?.workouts == 2)

        let earlierWeekStart = cal.dateInterval(of: .weekOfYear, for: day("2026-06-22"))!.start
        let earlierWeek = points.first { $0.date == earlierWeekStart }
        #expect(earlierWeek?.workouts == 1)
    }

    @Test func trendPointsMonthlyBucketsForYear() {
        let now = day("2026-07-08")
        let workouts = [
            workout(start: day("2026-07-02")),
            workout(start: day("2026-07-20")),
            workout(start: day("2026-06-15")),
        ]
        let points = ProgressPlanner.trendPoints(workouts: workouts, range: .year, calendar: cal, now: now)

        let july = points.first { $0.date == cal.dateInterval(of: .month, for: day("2026-07-02"))!.start }
        #expect(july?.workouts == 2)
        let june = points.first { $0.date == cal.dateInterval(of: .month, for: day("2026-06-15"))!.start }
        #expect(june?.workouts == 1)
    }

    @Test func trendPointsAllRangeStartsAtEarliestWorkout() {
        let now = day("2026-07-08")
        let workouts = [
            workout(start: day("2026-05-01")),
            workout(start: day("2026-07-08")),
        ]
        let points = ProgressPlanner.trendPoints(workouts: workouts, range: .all, calendar: cal, now: now)

        #expect(points.first?.date == cal.dateInterval(of: .month, for: day("2026-05-01"))!.start)
        #expect(points.last?.date == cal.dateInterval(of: .month, for: now)!.start)
        #expect(points.count == 3) // May, June, July
    }

    @Test func trendPointsEmptyInputReturnsEmptyArrayForAllRange() {
        let points = ProgressPlanner.trendPoints(workouts: [], range: .all, calendar: cal, now: day("2026-07-08"))
        #expect(points.isEmpty)
    }

    // MARK: - achievements

    @Test func achievementsCountMilestoneProgressReflectsCompletedCount() throws {
        let completedWorkouts = (1...5).map { workout(start: day("2026-07-0\($0)")) }
        let badges = ProgressPlanner.achievements(tracks: [], workouts: completedWorkouts,
                                                    calendar: cal, now: day("2026-07-08"))

        // 5 completed, target 10 — not earned, halfway there.
        let tenClub = try #require(badges.first { $0.id == "count-10" })
        #expect(tenClub.earned == false)
        #expect(tenClub.progress == 0.5)

        // 5 completed clears the 1-session milestone outright.
        let firstRep = try #require(badges.first { $0.id == "count-1" })
        #expect(firstRep.earned == true)
        #expect(firstRep.progress == 1)
    }

    @Test func achievementsEarlyBirdAndNightOwlFromStartHours() throws {
        let now = day("2026-07-08")

        let earlyBadges = ProgressPlanner.achievements(
            tracks: [], workouts: [workout(start: at(day("2026-07-01"), hour: 6))], calendar: cal, now: now)
        #expect(try #require(earlyBadges.first { $0.id == "early-bird" }).earned == true)
        #expect(try #require(earlyBadges.first { $0.id == "night-owl" }).earned == false)

        let lateBadges = ProgressPlanner.achievements(
            tracks: [], workouts: [workout(start: at(day("2026-07-01"), hour: 22))], calendar: cal, now: now)
        #expect(try #require(lateBadges.first { $0.id == "night-owl" }).earned == true)
        #expect(try #require(lateBadges.first { $0.id == "early-bird" }).earned == false)

        // Boundary hours: exactly 7 AM does not count as early (< 7 required); exactly
        // 9 PM does count as late (>= 21 required).
        let boundaryBadges = ProgressPlanner.achievements(
            tracks: [], workouts: [workout(start: at(day("2026-07-01"), hour: 7)),
                                    workout(start: at(day("2026-07-01"), hour: 21))],
            calendar: cal, now: now)
        #expect(try #require(boundaryBadges.first { $0.id == "early-bird" }).earned == false)
        #expect(try #require(boundaryBadges.first { $0.id == "night-owl" }).earned == true)
    }

    @Test func achievementsCleanSweepRequiresTwoNonVacuousGoalsHitInTheSameWeek() throws {
        let now = day("2026-07-08")

        // Only one goal is non-vacuously hit that week (the other is vacuous, i.e.
        // imposed nothing) — not a sweep, even though every present mark is "hit".
        let noSweep = [goalTrack(.strength, [mark(1, 1)]), goalTrack(.cardio, [mark(0, 0)])]
        let noSweepBadges = ProgressPlanner.achievements(tracks: noSweep, workouts: [], calendar: cal, now: now)
        #expect(try #require(noSweepBadges.first { $0.id == "clean-sweep" }).earned == false)

        // Two goals both non-vacuously hit the same week — a genuine sweep.
        let sweep = [goalTrack(.strength, [mark(1, 1)]), goalTrack(.cardio, [mark(2, 2)])]
        let sweepBadges = ProgressPlanner.achievements(tracks: sweep, workouts: [], calendar: cal, now: now)
        #expect(try #require(sweepBadges.first { $0.id == "clean-sweep" }).earned == true)
    }
}
