//
// WorkoutSessionManager.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//

import Foundation
import HealthKit
import Observation

/// Runs an `HKWorkoutSession` (with a live builder) for the duration of a watch
/// workout.
///
/// When the phone launches us via `startWatchApp(toHandle:)`, watchOS hands the app
/// delegate a workout configuration; starting a session with it grants the
/// freshly-launched app foreground runtime (and keeps it alive while the wrist is
/// down). The attached `HKLiveWorkoutBuilder` makes watchOS collect heart rate and
/// active energy from the sensors, so finishing the session saves a real `HKWorkout`
/// that feeds the Activity rings — and gives us the live HUD values and the metrics
/// we send back to the phone.
@Observable
@MainActor
final class WorkoutSessionManager: NSObject {
    private let healthStore = HKHealthStore()
    private var session: HKWorkoutSession?
    private var builder: HKLiveWorkoutBuilder?

    private(set) var isRunning = false

    /// When the running session started, for the coordinator's session-age rule (a
    /// too-young `.finish` demotes to `.discard`). `distantPast` for a recovered
    /// session — it predates this process, so it's never "young".
    private(set) var sessionStartDate: Date?

    /// Live values for the in-workout HUD. Nil until the first sample arrives (or if
    /// the corresponding read authorization was denied).
    private(set) var currentHeartRate: Double?
    private(set) var currentActiveEnergyKcal: Double?

    /// One live reading for the phone's status panel: the fresh heart rate plus the
    /// session's running calorie total and the HR zone (1–5) that rate falls in.
    /// `kcal`/`zone` are nil while unknown (no energy sample yet / max HR unknown).
    struct LiveSample {
        let bpm: Double
        let kcal: Double?
        let zone: Int?
    }

    /// Fired with each fresh heart-rate sample while the session runs. The bridge hangs
    /// off this to forward the reading to the phone (best-effort, display-only) — a
    /// callback rather than observation so the sensor stream has exactly one push path.
    var onHeartRateSample: ((LiveSample) -> Void)?

    /// Seconds accumulated in each of 5 heart-rate zones (low→high). Built on the
    /// watch because it's the only place with the full HR stream. Empty/ignored when
    /// the user's age (hence max HR) is unknown.
    private var hrZoneSeconds = [Double](repeating: 0, count: 5)
    private var lastHRSampleDate: Date?
    private var maxHeartRate: Double?

    /// Guards the terminal paths so a late delegate callback can't double-finish.
    private var isFinishing = false

    /// Prompt for the authorization the live session needs: share workouts + active
    /// energy (so the session can write them), read heart rate + active energy (to
    /// surface them), and read date of birth (to derive HR zones). Called once at
    /// launch so a later phone-initiated launch starts cleanly.
    func requestAuthorization() {
        guard HKHealthStore.isHealthDataAvailable() else { return }
        healthStore.requestAuthorization(
            toShare: WorkoutHealthAuthorization.watchShare,
            read: WorkoutHealthAuthorization.watchRead
        ) { _, _ in }
    }

    /// Start the session for a phone-launched workout. Idempotent — a second launch
    /// while one is already running is ignored.
    func start(with configuration: HKWorkoutConfiguration) {
        guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
        do {
            let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
            let builder = session.associatedWorkoutBuilder()
            builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
            session.delegate = self
            builder.delegate = self
            self.session = session
            self.builder = builder

            resetMetricAccumulators()
            maxHeartRate = computeMaxHeartRate()

            let startDate = Date()
            session.startActivity(with: startDate)
            Task { try? await builder.beginCollection(at: startDate) }
            sessionStartDate = startDate
            isRunning = true
        } catch {
            clear()
        }
    }

    /// Re-adopt a session that outlived a crash or reboot. watchOS keeps the
    /// `HKWorkoutSession` alive across an app death and relaunches us expecting a
    /// recovery call; without it the session is orphaned — its `HKWorkout` never saved
    /// and the live HUD dead. Called once at launch; a no-op when there's nothing to
    /// recover or a fresh start already claimed the slot.
    func recover() {
        guard HKHealthStore.isHealthDataAvailable(), session == nil else { return }
        Task {
            guard let recovered = try? await healthStore.recoverActiveWorkoutSession(),
                  self.session == nil else { return }
            adopt(recovered)
        }
    }

    private func adopt(_ recovered: HKWorkoutSession) {
        let builder = recovered.associatedWorkoutBuilder()
        builder.dataSource = HKLiveWorkoutDataSource(
            healthStore: healthStore, workoutConfiguration: recovered.workoutConfiguration)
        recovered.delegate = self
        builder.delegate = self
        self.session = recovered
        self.builder = builder

        // Zone seconds accumulated before the crash are gone (they lived in memory);
        // resume accumulation from here rather than faking the missing stretch.
        resetMetricAccumulators()
        maxHeartRate = computeMaxHeartRate()

        sessionStartDate = .distantPast
        isRunning = true
    }

    /// Finish the session and save it to Health, returning the captured metrics so the
    /// caller can attach them to the workout and forward to the phone. `totalVolume`
    /// is left nil here — the caller fills it from the workout's logs.
    func finishAndSave() async -> WorkoutMetrics? {
        guard let session, let builder, !isFinishing else { clear(); return nil }
        isFinishing = true
        let endDate = Date()
        session.end()
        do {
            try await builder.endCollection(at: endDate)
            let saved = try await builder.finishWorkout()
            let metrics = makeMetrics(savedWorkout: saved, endDate: endDate)
            clear()
            return metrics
        } catch {
            clear()
            return nil
        }
    }

    /// End the session and throw the data away — nothing is saved to Health. Used when
    /// the workout was discarded rather than completed.
    func discard() {
        guard let session, let builder, !isFinishing else { clear(); return }
        isFinishing = true
        session.end()
        builder.discardWorkout()
        clear()
    }

    // MARK: - Metric helpers

    private func makeMetrics(savedWorkout: HKWorkout?, endDate: Date) -> WorkoutMetrics {
        let hrUnit = HKUnit.count().unitDivided(by: .minute())
        let energy = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
            .sumQuantity()?.doubleValue(for: .kilocalorie())
        let hr = builder?.statistics(for: HKQuantityType(.heartRate))
        let zones = (maxHeartRate != nil && hrZoneSeconds.contains { $0 > 0 }) ? hrZoneSeconds : nil

        return WorkoutMetrics(
            activeEnergyKcal: energy,
            avgHeartRate: hr?.averageQuantity()?.doubleValue(for: hrUnit),
            maxHeartRate: hr?.maximumQuantity()?.doubleValue(for: hrUnit),
            minHeartRate: hr?.minimumQuantity()?.doubleValue(for: hrUnit),
            totalVolume: nil,
            hrZoneSeconds: zones,
            healthKitWorkoutUUID: savedWorkout?.uuid.uuidString,
            source: .watch,
            recordedAt: endDate
        )
    }

    /// Pull the latest live stats off the builder for the HUD, and fold the elapsed
    /// time into the right HR zone.
    private func refreshLiveStats() {
        let hrUnit = HKUnit.count().unitDivided(by: .minute())
        let newHR = builder?.statistics(for: HKQuantityType(.heartRate))?
            .mostRecentQuantity()?.doubleValue(for: hrUnit)
        let now = Date()

        if let maxHeartRate, let prevHR = currentHeartRate, let last = lastHRSampleDate {
            let dt = now.timeIntervalSince(last)
            if dt > 0, dt < 60 { hrZoneSeconds[Self.zoneIndex(for: prevHR, maxHR: maxHeartRate)] += dt }
        }

        // Refresh the calorie total before forwarding, so the sample carries it.
        currentActiveEnergyKcal = builder?.statistics(for: HKQuantityType(.activeEnergyBurned))?
            .sumQuantity()?.doubleValue(for: .kilocalorie())

        if let newHR {
            currentHeartRate = newHR
            let zone = maxHeartRate.map { Self.zoneIndex(for: newHR, maxHR: $0) + 1 }
            onHeartRateSample?(LiveSample(bpm: newHR, kcal: currentActiveEnergyKcal, zone: zone))
        }
        lastHRSampleDate = now
    }

    /// Bucket an instantaneous heart rate into one of five zones (0–4) by its fraction of the
    /// user's max HR: <60% → 0, 60–70% → 1, 70–80% → 2, 80–90% → 3, ≥90% → 4. Pure (no session
    /// or sensor state), so it's `nonisolated static` and unit-testable off the main actor.
    nonisolated static func zoneIndex(for hr: Double, maxHR: Double) -> Int {
        let ratio = hr / maxHR
        let thresholds = [0.6, 0.7, 0.8, 0.9]
        return thresholds.reduce(0) { $0 + (ratio >= $1 ? 1 : 0) }
    }

    /// Max HR ≈ 220 − age, from the user's date of birth. Nil when unavailable (read
    /// denied or DOB not set), which suppresses zone tracking rather than faking it.
    private func computeMaxHeartRate() -> Double? {
        guard let comps = try? healthStore.dateOfBirthComponents(), let birthYear = comps.year else { return nil }
        let thisYear = Calendar.current.component(.year, from: Date())
        let age = thisYear - birthYear
        guard age > 0, age < 120 else { return nil }
        return Double(220 - age)
    }

    private func resetMetricAccumulators() {
        currentHeartRate = nil
        currentActiveEnergyKcal = nil
        hrZoneSeconds = [Double](repeating: 0, count: 5)
        lastHRSampleDate = nil
        maxHeartRate = nil
    }

    /// The system ended the session out from under us (resource reclaim, watchOS policy).
    /// Salvage what the builder collected into a real Health save instead of dropping it —
    /// the run documents stay active, so no metrics are forwarded to the phone (there is no
    /// completed run to attach them to); the `HKWorkout` itself is what must not be lost.
    private func salvageSystemEndedSession(at endDate: Date) {
        guard let builder, !isFinishing else { clear(); return }
        isFinishing = true
        Task {
            defer { clear() }
            try? await builder.endCollection(at: endDate)
            _ = try? await builder.finishWorkout()
        }
    }

    private func clear() {
        session = nil
        builder = nil
        isRunning = false
        isFinishing = false
        sessionStartDate = nil
        resetMetricAccumulators()
    }
}

// MARK: - HKWorkoutSessionDelegate

extension WorkoutSessionManager: HKWorkoutSessionDelegate {
    nonisolated func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didChangeTo toState: HKWorkoutSessionState,
        from fromState: HKWorkoutSessionState,
        date: Date
    ) {
        guard toState == .ended || toState == .stopped else { return }
        // Only act if we didn't drive the end ourselves (finishAndSave/discard set
        // `isFinishing` and clear on their own). A system-ended session is salvaged
        // into a Health save rather than silently dropped.
        Task { @MainActor in
            if !self.isFinishing { self.salvageSystemEndedSession(at: date) }
        }
    }

    nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
        Task { @MainActor in self.clear() }
    }
}

// MARK: - HKLiveWorkoutBuilderDelegate

extension WorkoutSessionManager: HKLiveWorkoutBuilderDelegate {
    nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
        Task { @MainActor in self.refreshLiveStats() }
    }

    nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
}
