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

import Foundation
import SwiftData

/// What the watch should do with its `HKWorkoutSession` given the latest authoritative
/// workout set. `finish` carries the completed run whose metrics we save + forward.
enum SessionEndDecision: Equatable {
    case none
    case discard
    case finish(WorkoutDocument)
}

/// Pure decision seam for ending the watch's workout session — the session-free core the
/// `WorkoutSessionCoordinator` delegates to, mirroring the `WatchCacheApplier` pattern so the
/// end-of-run rule is unit-testable without HealthKit or a live cache.
///
/// The rule reconciles against the *authoritative* workout set (the same set the phone pushes
/// and the watch prunes to), not against any view's lifetime — a running session ends on a
/// genuine non-empty → empty transition of the active set. Comparing against the active set
/// captured at the *previous* reconcile is what makes the launch race safe: at startup the
/// session can be `running` before the run document has synced, but the previous active set is
/// still empty, so we never discard a run we simply haven't heard about yet.
enum SessionEndPlanner {
    /// Ids of the runs that keep the session alive — not-yet-finished (`notStarted` /
    /// `inProgress`). A `completed` / `skipped` run has dropped off the active set.
    static func activeIDs(in workouts: [WorkoutDocument]) -> Set<String> {
        Set(workouts
            .filter {
                let status = WorkoutStatus(rawValue: $0.status)
                return status == .notStarted || status == .inProgress
            }
            .map(\.id))
    }

    static func decide(isRunning: Bool,
                       previouslyActiveIDs: Set<String>,
                       workouts: [WorkoutDocument]) -> SessionEndDecision {
        guard isRunning,
              activeIDs(in: workouts).isEmpty,
              !previouslyActiveIDs.isEmpty else { return .none }
        // A previously-active run that survived as `.completed` is a finish (save + forward
        // metrics); if none survived, every active run vanished (discarded/deleted) → discard.
        let finished = previouslyActiveIDs
            .compactMap { id in workouts.first { $0.id == id } }
            .first { WorkoutStatus(rawValue: $0.status) == .completed }
        return finished.map(SessionEndDecision.finish) ?? .discard
    }
}

/// The single owner of watch workout-session termination. Long-lived (held by
/// `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate callback —
/// with or without a mounted view, including in the session's own background runtime. This is
/// the fix for the leak where ending a run *from the phone* while the watch app was
/// backgrounded left the `HKWorkoutSession` running (watchOS then re-foregrounded the app on
/// every wrist raise) and skipped the metrics capture entirely.
///
/// Same move as the live-mirror's `repairFromDurable`, one layer down: drive the terminal
/// action from the authoritative cache apply rather than a transient UI observer.
@MainActor
final class WorkoutSessionCoordinator {
    private let sessionManager: WorkoutSessionManager
    private let bridge: WatchConnectivityBridge
    private let context: ModelContext

    /// The active set captured at the previous reconcile — the baseline the non-empty → empty
    /// transition is measured against (see `SessionEndPlanner`).
    private var previouslyActiveIDs: Set<String> = []

    /// Guards the async finish so a burst of reconciles can't double-end the session.
    private var isEnding = false

    init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) {
        self.sessionManager = sessionManager
        self.bridge = bridge
        self.context = context
    }

    /// Re-evaluate the session against the current authoritative cache. Called after every
    /// cache mutation (phone push or the watch's own optimistic edit) and once at bootstrap to
    /// seed the baseline. The watch cache holds only active + recently-pruned runs, so the
    /// fetch-and-map is bounded and cheap.
    func reconcile() {
        guard !isEnding else { return }
        let workouts = ((try? context.fetch(FetchDescriptor<Workout>())) ?? [])
            .map(WorkoutDocument.init(from:))
        let decision = SessionEndPlanner.decide(
            isRunning: sessionManager.isRunning,
            previouslyActiveIDs: previouslyActiveIDs,
            workouts: workouts)
        previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts)
        switch decision {
        case .none:            return
        case .discard:         sessionManager.discard()
        case .finish(let doc): endAndForward(doc)
        }
    }

    /// Finish the session (saving the `HKWorkout` to Health), attach the captured metrics, and
    /// forward the run back to the phone. Lifted from the old `ActiveWorkoutGateView.endSession`
    /// so the behavior is unchanged — only its trigger moved off the view.
    private func endAndForward(_ doc: WorkoutDocument) {
        isEnding = true
        Task {
            defer { isEnding = false }
            guard var metrics = await sessionManager.finishAndSave() else { return }
            metrics.totalVolume = WorkoutVolume.total(doc.logs)
            var updated = doc
            updated.metrics = metrics
            updated.updatedAt = Date()
            bridge.update(workout: updated)   // optimistic upsert + forward (queued if offline)
        }
    }
}
