//
// ProgressPlanner.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//

import Foundation

// The Progress tab's derivation engine — a pure, unit-testable planner (precedent:
// `SeedReconcilePlanner`). Everything here is a deterministic function of plain value
// snapshots: per-schedule weekly adherence, per-goal rollups with streaks, trend
// buckets for the charts, and the achievements grid. Nothing is ever persisted —
// streaks and badges are recomputed from workout documents on every read, so they
// survive cache rebuilds by construction (see UX-REDESIGN.md).

// MARK: - Inputs

/// A plain snapshot of a `Schedule` cache entity. `routineID` must already be
/// live-resolved through `SyncEngine.currentRoutineID(for:)` so seed clone-on-edit
/// redirects don't orphan history.
struct ScheduleFacts: Sendable, Equatable {
    var id: String
    var goal: GoalKind?
    var recurrence: ScheduleRecurrence
    var weekdays: [Int]?      // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only
    var onceDate: Date?       // .once only
    var createdAt: Date
    var routineID: String     // live-resolved
    var routineName: String
}

/// A plain snapshot of a `Workout` cache entity. `routineID` live-resolved like
/// `ScheduleFacts.routineID`. `volume` is the caller's best value (recorded metric,
/// else derived from set entries); `activeMinutes` is the completed duration.
struct WorkoutFacts: Sendable, Equatable {
    var routineID: String?
    var start: Date
    var end: Date?
    var completed: Bool
    var volume: Double
    var activeMinutes: Double
    var energyKcal: Double
}

// MARK: - Adherence outputs

/// One schedule-week (or goal-week) verdict: how many required units were met.
/// `expected == 0` marks a *vacuous* week — the schedule imposed nothing (created
/// mid-week past its fixed days, a one-off in another week); vacuous weeks neither
/// break nor extend streaks.
struct WeekMark: Sendable, Equatable {
    var weekStart: Date
    var met: Int
    var expected: Int

    var hit: Bool { met >= expected }
    var isVacuous: Bool { expected == 0 }

    /// Fill fraction for the week-strip squares (0…1).
    var fraction: Double {
        expected > 0 ? min(1, Double(met) / Double(expected)) : 0
    }
}

/// One schedule's adherence across the track's week grid. `weeks` aligns 1:1 with the
/// grid (oldest → newest); `nil` = the schedule didn't exist / doesn't apply that week.
struct ScheduleTrack: Sendable, Equatable {
    var schedule: ScheduleFacts
    var weeks: [WeekMark?]
    /// The current week can no longer be met (a daily's missed day, or too few days
    /// left to cover a fixed day already missed). Distinct from "not yet hit".
    var currentWeekBroken: Bool

    var currentWeek: WeekMark? { weeks.last ?? nil }
}

/// A goal's rolled-up adherence: per-week sums across member schedules, plus the
/// streak. The goal is the ledger category — its track continues unbroken across
/// schedule rewrites (UX-REDESIGN.md).
struct GoalTrack: Sendable, Equatable, Identifiable {
    var goal: GoalKind?              // nil = the "Unassigned" group
    var scheduleTracks: [ScheduleTrack]
    var weeks: [WeekMark]            // rollup, one per grid week
    /// Consecutive fully-hit weeks ending now. The current week counts once hit;
    /// merely "not yet hit but still possible" neither counts nor breaks.
    var streakWeeks: Int
    /// False when any member schedule's current week is already unmeetable.
    var streakAlive: Bool

    var id: String { goal?.rawValue ?? "unassigned" }
}

// MARK: - Trend outputs

/// Chart time windows, per the trend-visualization pattern (7D/30D/90D/1Y/All).
enum TrendRange: String, CaseIterable, Identifiable, Sendable {
    case week = "7D"
    case month = "30D"
    case quarter = "90D"
    case year = "1Y"
    case all = "All"

    var id: String { rawValue }

    func startDate(now: Date, calendar: Calendar) -> Date? {
        switch self {
        case .week:    calendar.date(byAdding: .day, value: -7, to: now)
        case .month:   calendar.date(byAdding: .day, value: -30, to: now)
        case .quarter: calendar.date(byAdding: .day, value: -90, to: now)
        case .year:    calendar.date(byAdding: .year, value: -1, to: now)
        case .all:     nil
        }
    }

    /// Bucket granularity: short ranges read daily, a quarter weekly, longer monthly.
    var bucket: Calendar.Component {
        switch self {
        case .week, .month: .day
        case .quarter:      .weekOfYear
        case .year, .all:   .month
        }
    }
}

/// One chart bucket (a day, week, or month depending on the range).
struct TrendPoint: Sendable, Equatable, Identifiable {
    var date: Date          // bucket start
    var workouts: Int
    var volume: Double
    var activeMinutes: Double
    var energyKcal: Double

    var id: Date { date }
}

// MARK: - Achievements

/// A derived badge. `progress` is 0…1 toward earning (1 when earned) so locked badges
/// can show how close they are. `detail` is the one-line how-to-earn rule; `story` is
/// the longer read shown on the badge's detail screen — why the milestone matters and
/// what earning it testifies to.
struct Achievement: Sendable, Equatable, Identifiable {
    var id: String
    var title: String
    var detail: String
    var story: String
    var systemImage: String
    var colorName: String
    var earned: Bool
    var progress: Double
}

// MARK: - Planner

enum ProgressPlanner {

    // MARK: Goal tracks

    /// Roll schedules + workouts up into per-goal adherence tracks over the last
    /// `weeksBack` calendar weeks (oldest → newest, ending at the current week).
    /// Groups appear in `goalOrder`; schedules keep their input order within a group;
    /// goal-less schedules land in a trailing `goal == nil` group. Goals with no
    /// schedules produce no track.
    static func goalTracks(
        schedules: [ScheduleFacts],
        workouts: [WorkoutFacts],
        weeksBack: Int = 12,
        goalOrder: [GoalKind] = GoalKind.allCases,
        calendar: Calendar = .current,
        now: Date = Date()
    ) -> [GoalTrack] {
        let doneDays = completedDays(workouts: workouts, calendar: calendar)
        let grid = weekGrid(weeksBack: weeksBack, calendar: calendar, now: now)

        var groups: [(GoalKind?, [ScheduleFacts])] = goalOrder.map { kind in
            (kind, schedules.filter { $0.goal == kind })
        }
        groups.append((nil, schedules.filter { $0.goal == nil }))

        return groups.compactMap { goal, members in
            guard !members.isEmpty else { return nil }
            let tracks = members.map { schedule in
                scheduleTrack(schedule: schedule, doneDays: doneDays[schedule.routineID] ?? [],
                              grid: grid, calendar: calendar, now: now)
            }
            let rollup = grid.enumerated().map { index, weekStart in
                let marks = tracks.compactMap { $0.weeks[index] }
                return WeekMark(weekStart: weekStart,
                                met: marks.reduce(0) { $0 + $1.met },
                                expected: marks.reduce(0) { $0 + $1.expected })
            }
            let alive = !tracks.contains { $0.currentWeekBroken }
            return GoalTrack(goal: goal,
                             scheduleTracks: tracks,
                             weeks: rollup,
                             streakWeeks: streak(weeks: rollup, alive: alive),
                             streakAlive: alive)
        }
    }

    /// One schedule's marks across the grid plus its current-week brokenness.
    static func scheduleTrack(
        schedule: ScheduleFacts,
        doneDays: Set<Date>,
        grid: [Date],
        calendar: Calendar,
        now: Date
    ) -> ScheduleTrack {
        let weeks = grid.map { weekStart in
            weekMark(schedule: schedule, doneDays: doneDays, weekStart: weekStart,
                     calendar: calendar, now: now)
        }
        let broken = grid.last.map { currentWeekStart in
            currentWeekBroken(schedule: schedule, doneDays: doneDays,
                              weekStart: currentWeekStart, calendar: calendar, now: now)
        } ?? false
        return ScheduleTrack(schedule: schedule, weeks: weeks, currentWeekBroken: broken)
    }

    /// The verdict for one schedule-week, or nil when the schedule doesn't apply
    /// (created after the week, a one-off in a different week, a future week).
    ///
    /// Expectation semantics differ by recurrence so the *current* week reads as
    /// "on track so far" rather than "not done yet":
    /// - `.daily` / `.fixedDays`: expected counts only required days **up to today**;
    ///   met counts distinct completed days in that same window (so a Tuesday make-up
    ///   for a missed fixed Monday still counts — catch-ups are honored).
    /// - `.once`: expected 1, only in the week containing its date.
    static func weekMark(
        schedule: ScheduleFacts,
        doneDays: Set<Date>,
        weekStart: Date,
        calendar: Calendar,
        now: Date
    ) -> WeekMark? {
        guard let window = window(for: schedule, weekStart: weekStart,
                                  calendar: calendar, clampToDay: calendar.startOfDay(for: now))
        else { return nil }

        let done = window.elapsedDays.filter(doneDays.contains).count

        switch schedule.recurrence {
        case .daily:
            return WeekMark(weekStart: weekStart, met: done, expected: window.elapsedDays.count)
        case .fixedDays:
            let wanted = Set(schedule.weekdays ?? [])
            let required = window.elapsedDays
                .filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
            return WeekMark(weekStart: weekStart, met: min(done, required), expected: required)
        case .once:
            guard let once = schedule.onceDate,
                  calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
            else { return nil }
            return WeekMark(weekStart: weekStart, met: min(done, 1), expected: 1)
        }
    }

    /// True when the current week can no longer be met: the days remaining (today
    /// included) are fewer than the requirement still outstanding. A daily's missed
    /// yesterday breaks immediately; fixed days with catch-up days still left don't.
    static func currentWeekBroken(
        schedule: ScheduleFacts,
        doneDays: Set<Date>,
        weekStart: Date,
        calendar: Calendar,
        now: Date
    ) -> Bool {
        let today = calendar.startOfDay(for: now)
        guard let window = window(for: schedule, weekStart: weekStart,
                                  calendar: calendar, clampToDay: today)
        else { return false }

        let done = window.fullDays.filter(doneDays.contains).count
        let daysLeft = window.fullDays.filter { $0 >= today }.count

        let fullRequirement: Int
        switch schedule.recurrence {
        case .daily:
            fullRequirement = window.fullDays.count
        case .fixedDays:
            let wanted = Set(schedule.weekdays ?? [])
            fullRequirement = window.fullDays
                .filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
        case .once:
            guard let once = schedule.onceDate,
                  calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
            else { return false }
            fullRequirement = 1
        }
        return fullRequirement - done > daysLeft
    }

    // MARK: Trends

    /// Completed workouts bucketed for the chart: daily for 7D/30D, weekly for 90D,
    /// monthly for 1Y/All. Empty buckets are zero-filled from the range start (All:
    /// from the first workout) through now, so gaps read as gaps rather than the line
    /// skipping them. Empty input (or an empty window) returns [].
    static func trendPoints(
        workouts: [WorkoutFacts],
        range: TrendRange,
        calendar: Calendar = .current,
        now: Date = Date()
    ) -> [TrendPoint] {
        let cutoff = range.startDate(now: now, calendar: calendar)
        let completed = workouts.filter { workout in
            workout.completed && (cutoff.map { workout.start >= $0 } ?? true)
        }

        func bucketStart(_ date: Date) -> Date {
            calendar.dateInterval(of: range.bucket, for: date)?.start ?? date
        }

        var byBucket: [Date: TrendPoint] = [:]
        for workout in completed {
            let key = bucketStart(workout.start)
            var point = byBucket[key] ?? TrendPoint(date: key, workouts: 0, volume: 0,
                                                    activeMinutes: 0, energyKcal: 0)
            point.workouts += 1
            point.volume += workout.volume
            point.activeMinutes += workout.activeMinutes
            point.energyKcal += workout.energyKcal
            byBucket[key] = point
        }

        // Zero-fill from the window start so every bucket exists.
        let firstDate = cutoff ?? completed.map(\.start).min()
        guard let firstDate else { return [] }
        var points: [TrendPoint] = []
        var cursor = bucketStart(firstDate)
        let last = bucketStart(now)
        while cursor <= last {
            points.append(byBucket[cursor]
                ?? TrendPoint(date: cursor, workouts: 0, volume: 0, activeMinutes: 0, energyKcal: 0))
            guard let next = calendar.date(byAdding: range.bucket, value: 1, to: cursor),
                  next > cursor else { break }
            cursor = next
        }
        return points
    }

    // MARK: Achievements

    /// The full badge set, earned and locked alike — every one a pure derivation of
    /// history (recomputable after any cache rebuild). Streak badges read the goal
    /// tracks' best run, so their reach is capped by the tracks' `weeksBack`.
    static func achievements(
        tracks: [GoalTrack],
        workouts: [WorkoutFacts],
        calendar: Calendar = .current,
        now: Date = Date()
    ) -> [Achievement] {
        let completed = workouts.filter(\.completed)
        let count = completed.count
        let totalVolume = completed.reduce(0) { $0 + $1.volume }

        var badges: [Achievement] = []

        // Session-count milestones.
        let countMilestones: [(Int, String, String, String)] = [
            (1, "First Rep", "figure.walk",
             "Every training journey starts with a single session, and the first one is "
             + "the hardest rep of any program — it's the moment planning turned into "
             + "doing. This badge testifies to the step most people never take: you "
             + "showed up."),
            (10, "Ten Strong", "10.circle.fill",
             "Ten workouts is where a decision becomes a practice. Anyone can try "
             + "something once; coming back ten times means you pushed through the "
             + "early sessions where motivation does all the work and habit hasn't "
             + "formed yet. This badge testifies that you're building something real."),
            (50, "Fifty Club", "50.circle.fill",
             "Fifty sessions is months of consistent training — long enough for real "
             + "physiological change: heavier lifts, faster recovery, movements that "
             + "feel natural instead of awkward. This badge testifies to a body of "
             + "work, not a burst of enthusiasm."),
            (100, "Century", "medal.fill",
             "One hundred workouts represents hundreds of hours of deliberate effort, "
             + "and the kind of adaptation that only accumulates with time under load. "
             + "At this point training isn't something you do — it's part of who you "
             + "are. This badge testifies to identity, not intention."),
            (250, "Relentless", "flame.circle.fill",
             "Two hundred and fifty sessions is years of showing up — through busy "
             + "weeks, low motivation, and every good reason to skip. Very few people "
             + "ever train this much. This badge testifies to the rarest quality in "
             + "fitness: relentlessness."),
        ]
        for (target, title, symbol, story) in countMilestones {
            badges.append(Achievement(
                id: "count-\(target)", title: title,
                detail: "Complete \(target) workout\(target == 1 ? "" : "s")",
                story: story,
                systemImage: symbol, colorName: "blue",
                earned: count >= target,
                progress: min(1, Double(count) / Double(target))))
        }

        // Weekly-streak milestones — best consecutive-hit run across any goal track.
        let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0
        let streakMilestones: [(Int, String, String)] = [
            (4, "4-Week Rhythm",
             "Four straight weeks of hitting a goal is your first real training rhythm. "
             + "Progress comes from consistency week over week, not from one heroic "
             + "week — and a month is where a plan starts to become a pattern. This "
             + "badge testifies that you can sustain intent, not just spark it."),
            (8, "8-Week Habit",
             "Eight consecutive weeks is the length of a classic training block — the "
             + "horizon where measurable strength and conditioning gains actually show "
             + "up. Holding a streak this long means training survived vacations, "
             + "deadlines, and dips in motivation. This badge testifies that the habit "
             + "now carries you, not the other way around."),
            (12, "12-Week Lifestyle",
             "Twelve weeks — a full quarter — of hitting your goal every single week. "
             + "Programs end; lifestyles don't. A streak this long means training is no "
             + "longer an experiment you're running, it's simply how you live. This "
             + "badge testifies to exactly that."),
        ]
        for (target, title, story) in streakMilestones {
            badges.append(Achievement(
                id: "streak-\(target)", title: title,
                detail: "Hit a goal every week for \(target) weeks straight",
                story: story,
                systemImage: "flame.fill", colorName: "orange",
                earned: bestRun >= target,
                progress: min(1, Double(bestRun) / Double(target))))
        }

        // Consecutive active days.
        let dayRun = longestDayRun(workouts: completed, calendar: calendar)
        badges.append(Achievement(
            id: "days-7", title: "Seven Straight",
            detail: "Work out 7 days in a row",
            story: "A full week without a single day off takes planning, recovery "
                + "management, and follow-through every single morning. It testifies to "
                + "discipline and momentum — the ability to keep a promise to yourself "
                + "seven days running. Wear it proudly, and remember rest is training "
                + "too.",
            systemImage: "calendar", colorName: "green",
            earned: dayRun >= 7,
            progress: min(1, Double(dayRun) / 7)))

        // Lifetime volume.
        let volumeMilestones: [(Double, String, String, String)] = [
            (100_000, "Heavy Lifter", "scalemass.fill",
             "A hundred thousand in lifted volume — every rep of every set, added up. "
             + "Volume is the primary driver of muscle growth, and a number this size "
             + "only comes from thousands of purposeful reps. This badge is the tonnage "
             + "receipt for your work: proof the effort was real and measured."),
            (1_000_000, "Million Mover", "trophy.fill",
             "One million in total volume. Nobody reaches seven figures by accident — "
             + "it's the compound interest of years of training, session after session, "
             + "plate after plate. This badge testifies to a scale of sustained work "
             + "that puts you in genuinely rare company."),
        ]
        for (target, title, symbol, story) in volumeMilestones {
            badges.append(Achievement(
                id: "volume-\(Int(target))", title: title,
                detail: "Lift \(Int(target).formatted()) total volume",
                story: story,
                systemImage: symbol, colorName: "purple",
                earned: totalVolume >= target,
                progress: min(1, totalVolume / target)))
        }

        // Time-of-day one-offs.
        let hours = completed.map { calendar.component(.hour, from: $0.start) }
        badges.append(Achievement(
            id: "early-bird", title: "Early Bird",
            detail: "Start a workout before 7 AM",
            story: "Training before 7 AM means putting the workout ahead of everything "
                + "the day is about to throw at you. Morning sessions are the ones life "
                + "can't cancel — no meeting runs into them, no fatigue talks you out of "
                + "them. This badge testifies that you make time rather than find it.",
            systemImage: "sunrise.fill", colorName: "yellow",
            earned: hours.contains { $0 < 7 },
            progress: hours.contains { $0 < 7 } ? 1 : 0))
        badges.append(Achievement(
            id: "night-owl", title: "Night Owl",
            detail: "Start a workout after 9 PM",
            story: "Starting a workout after 9 PM takes a different kind of resolve — "
                + "willpower runs lowest at the end of the day, when the couch is "
                + "closest and every excuse is at hand. This badge testifies that you "
                + "train even when it would be easy not to.",
            systemImage: "moon.stars.fill", colorName: "indigo",
            earned: hours.contains { $0 >= 21 },
            progress: hours.contains { $0 >= 21 } ? 1 : 0))

        // Clean sweep: some week in which every goal's requirement was fully hit.
        let sweep = cleanSweepExists(tracks: tracks)
        badges.append(Achievement(
            id: "clean-sweep", title: "Clean Sweep",
            detail: "Hit every goal in a single week",
            story: "In one week you hit every goal you set — not one focus at the "
                + "expense of the others, but the whole plan, executed. Balance is the "
                + "hardest thing to train because nothing about it is urgent. This "
                + "badge testifies to well-rounded programming and the discipline to "
                + "honor all of it at once.",
            systemImage: "checkmark.seal.fill", colorName: "teal",
            earned: sweep, progress: sweep ? 1 : 0))

        return badges
    }

    // MARK: - Shared helpers

    /// Start-of-day set of every day with a completed workout, keyed by routine ID.
    static func completedDays(workouts: [WorkoutFacts], calendar: Calendar) -> [String: Set<Date>] {
        var result: [String: Set<Date>] = [:]
        for workout in workouts where workout.completed {
            guard let routineID = workout.routineID else { continue }
            result[routineID, default: []].insert(calendar.startOfDay(for: workout.start))
        }
        return result
    }

    /// Week starts oldest → newest, ending at the week containing `now`.
    static func weekGrid(weeksBack: Int, calendar: Calendar, now: Date) -> [Date] {
        guard weeksBack > 0,
              let currentStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start
        else { return [] }
        return (0..<weeksBack).reversed().compactMap {
            calendar.date(byAdding: .weekOfYear, value: -$0, to: currentStart)
        }
    }

    /// The days of one schedule-week: `fullDays` is the whole week clipped to the
    /// schedule's creation day; `elapsedDays` additionally clips to `clampToDay`
    /// (today), for "so far" expectations. Nil when the schedule postdates the week.
    private static func window(
        for schedule: ScheduleFacts,
        weekStart: Date,
        calendar: Calendar,
        clampToDay: Date
    ) -> (fullDays: [Date], elapsedDays: [Date])? {
        guard let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart)
        else { return nil }
        let createdDay = calendar.startOfDay(for: schedule.createdAt)
        let start = max(weekStart, createdDay)
        guard start < weekEnd, start <= clampToDay else { return nil }

        var fullDays: [Date] = []
        var cursor = start
        while cursor < weekEnd {
            fullDays.append(cursor)
            guard let next = calendar.date(byAdding: .day, value: 1, to: cursor), next > cursor
            else { break }
            cursor = next
        }
        let elapsedDays = fullDays.filter { $0 <= clampToDay }
        return (fullDays, elapsedDays)
    }

    /// Streak ending now: consecutive hit weeks scanned newest → oldest. A vacuous
    /// week is transparent (skipped); the current week counts once hit, is skipped
    /// while still meetable, and zeroes the streak when already broken.
    static func streak(weeks: [WeekMark], alive: Bool) -> Int {
        guard !weeks.isEmpty else { return 0 }
        var remaining = weeks
        let current = remaining.removeLast()

        var count = 0
        if !current.isVacuous {
            if current.hit { count += 1 }
            else if !alive { return 0 }
            // else: still in play — neither counts nor breaks.
        }
        for week in remaining.reversed() {
            if week.isVacuous { continue }
            if week.hit { count += 1 } else { break }
        }
        return count
    }

    /// Longest run of consecutive hit weeks anywhere in the track (vacuous weeks are
    /// transparent here too).
    static func bestHitRun(weeks: [WeekMark]) -> Int {
        var best = 0, run = 0
        for week in weeks {
            if week.isVacuous { continue }
            if week.hit { run += 1; best = max(best, run) } else { run = 0 }
        }
        return best
    }

    /// Longest run of consecutive calendar days with at least one completed workout.
    static func longestDayRun(workouts: [WorkoutFacts], calendar: Calendar) -> Int {
        let days = Set(workouts.filter(\.completed).map { calendar.startOfDay(for: $0.start) })
            .sorted()
        var best = 0, run = 0
        var previous: Date?
        for day in days {
            if let previous,
               let next = calendar.date(byAdding: .day, value: 1, to: previous),
               calendar.isDate(next, inSameDayAs: day) {
                run += 1
            } else {
                run = 1
            }
            best = max(best, run)
            previous = day
        }
        return best
    }

    /// True when some grid week saw every goal track's requirement fully hit —
    /// non-vacuously for at least two goals (a single-goal "sweep" is just a hit week).
    static func cleanSweepExists(tracks: [GoalTrack]) -> Bool {
        guard let weekCount = tracks.first?.weeks.count, !tracks.isEmpty else { return false }
        for index in 0..<weekCount {
            let marks = tracks.compactMap { $0.weeks.indices.contains(index) ? $0.weeks[index] : nil }
            let active = marks.filter { !$0.isVacuous }
            if active.count >= 2 && marks.allSatisfy(\.hit) { return true }
        }
        return false
    }
}
