//
// ExerciseProgressView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//

import os
import SwiftUI
import UIKit

/// Runs a single exercise as a horizontally-paged flow — the iPhone counterpart to the
/// watch's `ExerciseProgressView`:
///
///   [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish
///
/// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a
/// resumed exercise jumps straight to its first unfinished set). The **work** phase
/// counts *up* (a stopwatch for the current set, tinted with the brand purple); the
/// user swipes left when they're done. The **rest** phase counts *down* from the
/// configurable rest time (light teal), buzzes once per second in the final three
/// seconds, then auto-advances to the next work phase. Sliding past the final set
/// reaches a **Finish** page offering **One More** (append a bonus set) and **Done**
/// (which also auto-fires after a configurable countdown, completing the exercise).
///
/// The paged flow occupies the **top half** of the screen; the bottom half shows the
/// animated form-guide figure when the exercise has a bundled motion
/// (`ExerciseFigureSlot`). Between the two halves sits the **live status panel** —
/// heart rate (tinted by the target band on cardio runs), HR zone, calories, and the
/// workout stopwatch in big glanceable digits. A row of phase dots tracks progress:
/// purple for work, teal for rest, with the current phase drawn as a wider dash.
struct ExerciseProgressView: View {
    /// Run-flow breadcrumbs (page transitions, applied frames, repairs) — the mirror's
    /// failure modes are cross-device timing races, unattributable without a trail.
    private static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "run-flow")

    @Environment(\.dismiss) private var dismiss
    @Environment(\.verticalSizeClass) private var verticalSizeClass
    @Environment(\.modelContext) private var modelContext
    @Environment(SyncEngine.self) private var sync

    /// Live watch-forwarded metrics (heart rate, calories, zone — see `LiveRunState`)
    /// — they drive the status panel between the two halves.
    @Environment(LiveRunState.self) private var liveRun


    /// The shared working workout document owned by the parent list. We mutate the
    /// matching log in place and ask the parent to persist each change — driving the UI
    /// from this doc (not the cache) avoids losing rapid edits to the read-after-write
    /// race the cache update lags behind.
    @Binding var doc: WorkoutDocument
    let logID: String
    let onChange: () -> Void

    /// True when this run was auto-advanced into from the previous exercise (flow mode).
    /// It suppresses the Ready page and begins the exercise immediately on appear — the
    /// whole point of a hands-free flowing routine.
    let enteredViaFlow: Bool

    /// Invoked at the terminal rest's end in flow mode to hand off to the next exercise;
    /// the parent `RunFlowView` swaps the presented log. Nil for a non-advancing run
    /// (the terminal page is then the normal Finish page that dismisses).
    var onAdvance: ((String) -> Void)? = nil

    /// Broadcasts the current flow position so the watch (if it has this run open) can follow
    /// it live (ephemeral; see `LiveProgress`). `onLiveEnded` fires when we leave the flow.
    /// Only *human* transitions are broadcast — an auto-advance (rest/timed-work end) isn't,
    /// since the other device reaches it independently off the same wall-clock anchors. Default
    /// no-ops, so the in-list driver stays purely durable; the propped-phone cover wires these.
    let onLive: (LiveProgress) -> Void
    let onLiveEnded: () -> Void

    /// Reflects the run's current position onto the iOS Live Activity (lock screen + Dynamic
    /// Island). Unlike `onLive` (watch, *human* transitions only), this fires on **every** page
    /// settle — human, auto-advance, or a followed watch frame — plus the initial page, since the
    /// Live Activity must track the current phase and countdown no matter who drove it.
    /// `onActivityEnded` fires when the flow is left, so the activity can be dismissed. Both
    /// default to no-ops, so the in-list driver only wires them where an activity is wanted.
    let onActivity: (LiveProgress) -> Void
    let onActivityEnded: () -> Void

    /// The latest live-run frame the *watch* sent for this run, to follow when it drives a
    /// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page
    /// without re-broadcasting or re-recording — the originating device owns the durable write.
    var incomingFrame: LiveProgress?

    /// Reads the exercise's setup/form cues aloud when the user taps Start, if
    /// `speakExerciseCues` is on. Optional so decoupled hosts (the screenshot rig) that
    /// don't inject `AppServices` simply stay silent.
    var speechAnnouncer: SpeechAnnouncer? = nil

    /// Rest length between sets, shared with the watch via the same defaults key.
    @AppStorage("restSeconds") private var restSeconds: Int = 45

    /// Read aloud the setup/form cues when an exercise starts (hands-free). Off by default;
    /// opt in from Settings. Governs only the automatic cue — the library Speak button is
    /// always available.
    @AppStorage("speakExerciseCues") private var speakExerciseCues = false

    /// What the automatic narration says (only when `speakExerciseCues` is on): the concise
    /// coming-up/countdown cues, the setup & form read, or both.
    @AppStorage("spokenCueStyle") private var spokenCueStyle: SpokenCueStyle = .cues

    /// Auto-Done countdown on the Finish page — read so a broadcast frame carries the same
    /// end anchor the watch's mirror counts off.
    @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5

    /// Display unit for the adjust affordance (stored weights stay unit-less).
    @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb

    /// Presents the compact adjust sheet for the just-completed set's entry.
    @State private var showingAdjust = false

    /// Planned set count for this run. `One More` bumps it (and the log's `sets`).
    @State private var setCount: Int
    @State private var currentPage: Int
    @State private var didRestorePage = false

    /// Why `currentPage` last changed, so the page observer knows whether to broadcast it.
    /// A human swipe leaves this `nil` (→ treated as human); programmatic moves set it.
    @State private var pageChangeCause: PageChangeCause?

    /// Highest remote frame we've applied — version plus its anchor tie-break (see
    /// `LiveProgress.isNewer`) — so a redelivery or collided stale frame doesn't re-jump.
    @State private var lastAppliedVersion = 0
    @State private var lastAppliedStart = Date.distantPast

    /// Highest completed-set count this screen has itself recorded or followed via a live
    /// frame — the baseline `repairFromDurable` compares an absorbed doc against, so a
    /// durable echo of our own progress (or of a frame we followed) never triggers a jump.
    @State private var knownStateIndex: Int

    /// A page reached with a *known* wall-clock anchor counts its timer from that anchor
    /// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery
    /// latency can't desync the mirror) and a chained auto-advance (the finished phase's
    /// computed end, so a ticker that slept through the boundary — wrist-down throttling —
    /// can't bake its lateness into the next phase's timer). Scoped to one page; a human
    /// transition to another page self-anchors.
    @State private var pageAnchor: PageAnchor?

    private struct PageAnchor {
        let page: Int
        let start: Date
        let end: Date?
    }

    private enum PageChangeCause { case auto, remote }

    /// True when this run opened on a resumed set (in-progress) rather than the Ready
    /// page. Held in `@State` so it stays fixed for the life of the screen — the parent
    /// list re-inits this view whenever the workout file changes, and this must not flip
    /// mid-run. Such a run re-asserts its resume page after the first layout and ignores
    /// the transient TabView snap-to-0, so it isn't reset on open.
    @State private var startsResumed: Bool

    /// True when the exercise was already completed when this screen opened — it shows a
    /// static Completed page instead of dropping back into the timer flow. Fixed at init
    /// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
    @State private var startsCompleted: Bool

    /// True when the exercise was skipped (workout ended early) when this screen opened —
    /// it shows a static Skipped page instead of the timer flow. Fixed at init like
    /// `startsCompleted`.
    @State private var startsSkipped: Bool

    /// Set when *this* screen resolves the exercise (Done / flow hand-off), so the
    /// remote-flip observer doesn't mistake our own terminal write for a peer's.
    @State private var locallyResolved = false

    /// Where the resume landed, computed once on appear (with its timer anchor) so the
    /// post-layout re-assert jumps to the same page the anchor was built for.
    @State private var resumeTargetPage: Int?

    /// The leading header button. The default chevron dismisses like a nav back button;
    /// hosts that present modally (the live-mirror cover) swap in their own symbol/action.
    var closeSymbol: String = "chevron.backward"
    var onClose: (() -> Void)? = nil

    init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, speechAnnouncer: SpeechAnnouncer? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil, closeSymbol: String = "chevron.backward", onClose: (() -> Void)? = nil) {
        self._doc = doc
        self.logID = logID
        self.onChange = onChange
        self.onLive = onLive
        self.onLiveEnded = onLiveEnded
        self.onActivity = onActivity
        self.onActivityEnded = onActivityEnded
        self.incomingFrame = incomingFrame
        self.speechAnnouncer = speechAnnouncer
        self.enteredViaFlow = enteredViaFlow
        self.onAdvance = onAdvance
        self.closeSymbol = closeSymbol
        self.onClose = onClose

        let log = doc.wrappedValue.logs.first { $0.id == logID }
        let sets = max(1, log?.sets ?? 1)
        _setCount = State(initialValue: sets)
        _knownStateIndex = State(initialValue: max(0, log?.currentStateIndex ?? 0))

        // The Ready page always leads the flow. A not-started run opens on it; an
        // in-progress run opens on its first unfinished set and re-asserts that page past
        // the TabView's snap-to-0 on first layout.
        let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
        _startsResumed = State(initialValue: !notStarted)
        _startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
        _startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue)

        // A flow-advanced entry has no Ready page (base 0), so page 0 is its first work set.
        let base = enteredViaFlow ? 0 : 1
        // Resume on the first unfinished set's work page (clamped to the last set).
        let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
        let resume = base + completed * 2
        _currentPage = State(initialValue: notStarted ? 0 : resume)

        // A resumed run refines the landing to the phase the exercise is actually in
        // (mid-rest lands on the rest page) with its wall-clock anchor, so the timer
        // continues instead of restarting. Computed here — not on appear — because the
        // paged TabView must *initialize* on this page: a post-layout backward jump
        // wedges its pager, which then ignores the next animated programmatic advance.
        if !notStarted, log?.status == WorkoutStatus.inProgress.rawValue {
            let refined = computeResume()
            _currentPage = State(initialValue: refined.page)
            _resumeTargetPage = State(initialValue: refined.page)
            _pageAnchor = State(initialValue: refined.anchor)
        }
    }

    private var log: WorkoutLogDocument? {
        doc.logs.first { $0.id == logID }
    }

    /// The **Ready?** page leads the flow so a resumed run can swipe back to it (which
    /// resets the exercise). A flow-advanced entry skips it — the exercise begins on
    /// appear, hands-free.
    private var showsReady: Bool { !enteredViaFlow }

    /// This routine runs as a continuous flow — finishing one exercise rests, then
    /// auto-advances into the next — rather than returning to the list per exercise.
    private var autoAdvance: Bool { doc.autoAdvance == true }

    /// Rest length for this run: the routine's per-routine override, else the global default.
    /// Governs both between-set rests and (in flow mode) the between-exercise rest.
    private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) }

    /// The next not-yet-resolved exercise after this one, in plan order — the hand-off
    /// target for flow mode. Nil when this is the last unfinished exercise (→ the run ends).
    private var nextUnfinishedLogID: String? {
        let sorted = doc.logs.sorted { $0.order < $1.order }
        guard let idx = sorted.firstIndex(where: { $0.id == logID }) else { return nil }
        return sorted[(idx + 1)...].first { log in
            let status = WorkoutStatus(rawValue: log.status) ?? .notStarted
            return status == .notStarted || status == .inProgress
        }?.id
    }

    /// The next not-yet-resolved log after this one, in plan order — the flow hand-off
    /// target's full document, so both its display and library names are reachable.
    /// Nil when this is the last unfinished exercise.
    private var nextLog: WorkoutLogDocument? {
        guard let id = nextUnfinishedLogID else { return nil }
        return doc.logs.first { $0.id == id }
    }

    /// Name of the next not-yet-resolved exercise (the flow hand-off target), or nil when
    /// this is the last one. Drives the between-exercise rest's "Coming up …" preview.
    private var nextExerciseName: String? { nextLog?.exerciseName }

    /// The between-exercise rest — flow mode's terminal page when another exercise follows.
    /// This is the only rest whose "next" is a *different* exercise, so it's the one that
    /// previews the next figure and speaks "Coming up …".
    private var isOnBetweenExerciseRest: Bool {
        autoAdvance && nextUnfinishedLogID != nil && currentPage == base + cycleCount
    }

    /// Which exercise the bottom-half figure should show: the *next* one while resting
    /// between exercises (so you see what's coming up), otherwise this exercise. This
    /// is the display name (the headline); `figureLibraryExerciseName` is its figure/
    /// info counterpart.
    private var figureExerciseName: String {
        if isOnBetweenExerciseRest, let next = nextExerciseName { return next }
        return log?.exerciseName ?? ""
    }

    /// The library name to resolve the bottom-half figure against — the same
    /// this-exercise-or-next-during-rest choice as `figureExerciseName`, but the
    /// library name the animation/info actually key on.
    private var figureLibraryExerciseName: String {
        if isOnBetweenExerciseRest, let next = nextLog { return libraryIdentity(next) }
        return libraryIdentity(log)
    }

    /// Routine exercise name → library identity, resolved once on appear. Display-only
    /// fallback for logs minted before the per-log `libraryName` snapshot existed —
    /// without it a renamed exercise from an older workout loses its figure and guide.
    @State private var routineLibraryNames: [String: String] = [:]

    /// A log's library identity: its own snapshot when present, else the routine's
    /// same-named exercise (legacy logs), else the exercise name itself.
    private func libraryIdentity(_ log: WorkoutLogDocument?) -> String {
        guard let log else { return "" }
        return log.libraryName ?? routineLibraryNames[log.exerciseName] ?? log.exerciseName
    }

    /// Fill `routineLibraryNames` from the workout's routine (following the seed
    /// clone-on-edit redirect), so `libraryIdentity` can repair legacy logs.
    private func resolveRoutineLibraryNames() {
        guard let routineID = doc.routineID,
              let routine = CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID),
                                                     in: modelContext) else { return }
        routineLibraryNames = Dictionary(
            routine.exercisesArray.map { ($0.name, $0.libraryExerciseName) },
            uniquingKeysWith: { first, _ in first })
    }

    /// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
    private var base: Int { showsReady ? 1 : 0 }

    /// Work/rest pages: Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1.
    private var cycleCount: Int { setCount * 2 - 1 }

    /// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
    private var totalPages: Int { base + cycleCount + 1 }

    /// The first unfinished set's work page, used to resume an in-progress run.
    private var resumePage: Int {
        let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
        return base + completed * 2
    }

    /// Completed-set count a given page implies — the same math `recordProgress` records
    /// (capped at `setCount − 1`; only an explicit Done marks the final set).
    private func completedSets(for pageIndex: Int) -> Int {
        min(max(0, (pageIndex - base + 1) / 2), setCount - 1)
    }

    /// Position within the work/rest cycle for the current page — `nil` on the Ready
    /// and Finish pages (which show no dots).
    private var currentCycleIndex: Int? {
        let c = currentPage - base
        return (0..<cycleCount).contains(c) ? c : nil
    }

    /// Work-only progress model for the dot row: one marker per set, a dash on the set
    /// being worked, and a doubled gap straddling the rest you're currently in.
    private var workDots: WorkPhaseDots.Model? {
        guard let c = currentCycleIndex else { return nil }
        if c.isMultiple(of: 2) {
            let set = c / 2
            return .init(setCount: setCount, activeSet: set, restAfterSet: nil, completed: set)
        } else {
            let set = (c - 1) / 2
            return .init(setCount: setCount, activeSet: nil, restAfterSet: set, completed: set + 1)
        }
    }

    private var detail: String {
        guard let log else { return "" }
        if LoadType(rawValue: log.loadType) == .duration {
            return Self.durationLabel(log.durationSeconds)
        }
        return "\(log.reps) reps"
    }

    /// Timed exercise: the work phase counts *down* from its duration (and auto-advances),
    /// rather than counting *up* until the user swipes on.
    private var isDuration: Bool {
        guard let log else { return false }
        return LoadType(rawValue: log.loadType) == .duration
    }

    /// Per-set work duration for a timed exercise.
    private var workDurationSeconds: Int {
        max(1, log?.durationSeconds ?? 1)
    }

    /// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps".
    private var readySummary: String {
        let setsText = "\(setCount) set\(setCount == 1 ? "" : "s")"
        return detail.isEmpty ? setsText : "\(setsText) × \(detail)"
    }

    /// The half-and-half split: top/bottom in portrait, side-by-side in landscape
    /// (compact vertical size class), so neither half ends up squeezed into a
    /// short rotated band. `AnyLayout` preserves view identity across rotation —
    /// the paged flow's position and running timers survive the swap.
    private var splitLayout: AnyLayout {
        verticalSizeClass == .compact
            ? AnyLayout(HStackLayout(spacing: 0))
            : AnyLayout(VStackLayout(spacing: 0))
    }

    /// Scales the header exercise-name headline with Dynamic Type.
    @ScaledMetric(relativeTo: .largeTitle) private var headerNameFontSize: CGFloat = 30

    /// Headline for the header bar: "Library · Name" once an exercise was renamed —
    /// both identities on one line — else just the name.
    private var headerTitle: String {
        let name = figureExerciseName
        let library = figureLibraryExerciseName
        guard !library.isEmpty, library != name else { return name }
        return "\(library) · \(name)"
    }

    /// Replaces the navigation bar in both orientations: the back chevron top-left and
    /// the exercise name — big type readable from a propped phone — where the bar was.
    /// During a between-exercise rest it previews the *next* exercise, matching the
    /// figure below.
    private var headerBar: some View {
        ZStack {
            Text(headerTitle)
                .font(.system(size: headerNameFontSize, weight: .bold, design: .rounded))
                .lineLimit(1)
                .minimumScaleFactor(0.4)
                .padding(.horizontal, 52)   // stay clear of the chevron, centered on screen
                .frame(maxWidth: .infinity)

            HStack {
                Button {
                    if let onClose { onClose() } else { dismiss() }
                } label: {
                    Image(systemName: closeSymbol)
                        .font(.title3.weight(.semibold))
                        .frame(width: 44, height: 44)
                        .contentShape(Rectangle())
                }
                .accessibilityLabel("Back")
                Spacer()
            }
        }
    }

    var body: some View {
        VStack(spacing: 0) {
            headerBar
            if startsCompleted {
                // Same half-and-half split as the run flow, with the Completed badge where
                // the paged flow would be, so the form-guide figure stays on screen.
                splitLayout {
                    CompletedPhaseView(log: log)
                    ExerciseFigureSlot(exerciseName: libraryIdentity(log))
                }
            } else if startsSkipped {
                splitLayout {
                    SkippedPhaseView()
                    ExerciseFigureSlot(exerciseName: libraryIdentity(log))
                }
            } else {
                flowBody
            }
        }
        // Repair legacy logs' library identity from the routine before anything keys
        // off it (figure, headline, spoken cues).
        .onAppear(perform: resolveRoutineLibraryNames)
        // The flow owns its chrome: the nav bar is hidden in both orientations (the
        // header above carries the name and back chevron in its place).
        .toolbar(.hidden, for: .navigationBar)
        // Landscape's side-by-side split is already starved for height — hide the tab
        // bar there and give the row the full screen. Portrait keeps it.
        .toolbarVisibility(verticalSizeClass == .compact ? .hidden : .automatic, for: .tabBar)
    }

    private var flowBody: some View {
        splitLayout {
            // Paged flow — top half.
            TabView(selection: $currentPage) {
                ForEach(0..<totalPages, id: \.self) { index in
                    page(for: index)
                        .tag(index)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay(alignment: .bottom) {
                if let dots = workDots {
                    WorkPhaseDots(model: dots)
                        .padding(.bottom, 8)
                }
            }

            // The live status panel sits between the halves in both orientations.
            statusPanel

            // Bottom half: the looping form-guide figure. Shows the *next* exercise while
            // resting between exercises (a live preview of what's coming up), otherwise this
            // one. The name swap re-loads the figure and carries through the hand-off, so it
            // stays put as the next exercise takes over.
            ExerciseFigureSlot(exerciseName: figureLibraryExerciseName)
        }
        .onChange(of: currentPage) { oldPage, newPage in
            // Ignore page changes until the initial resume settles, so the TabView's
            // transient snap-to-0 on first layout can't reset an in-progress run.
            guard didRestorePage else { return }
            let cause = pageChangeCause
            pageChangeCause = nil
            Self.log.info("page \(oldPage) → \(newPage) cause=\(cause.map { "\($0)" } ?? "human", privacy: .public) log=\(logID, privacy: .public)")
            switch cause {
            case .remote:
                // The watch already recorded and owns this transition — just follow it.
                break
            case .auto:
                // Rest / timed-work auto-advance: record forward progress, but don't
                // broadcast — the watch reaches this point on its own synchronized timer.
                // The chained anchor for the new page was set by `advance`, not cleared.
                recordProgress(for: newPage)
            case .none:
                // A human swipe. Swiping back from the first set to Ready wipes the run
                // (only the adjacent 1→0 swipe resets — a stray far jump never does); any
                // other page records forward progress. Human transitions are broadcast.
                clearAnchor()
                if showsReady && newPage == 0 && oldPage == base {
                    resetExercise()
                } else {
                    recordProgress(for: newPage)
                }
                broadcastLive(for: newPage)
            }
            // The Live Activity tracks every settle (all causes), not just human ones — a rest
            // that auto-advances or a page the watch drove must still refresh the lock screen.
            emitActivity(for: newPage)
        }
        .onChange(of: incomingFrame) { _, frame in
            if let frame { applyIncoming(frame) }
        }
        .onChange(of: log?.currentStateIndex) { _, _ in
            repairFromDurable()
        }
        .onChange(of: log?.status) { _, raw in
            // The log resolved out from under us (the run was completed or ended early on
            // another device). Staying would let the next recorded set write a resolved log
            // back to in-progress, resurrecting it through the merge. In flow mode a remote
            // *completion* means the driver has already handed off — follow it to the next
            // unfinished exercise instead of falling out of the run (which would tear down
            // the Live Activity and strand the flow in the mirror cover). Everything else
            // (skipped, last exercise, non-flow) leaves the flow as before.
            // `locallyResolved` keeps our own Done / flow hand-off from re-triggering this.
            guard didRestorePage, !locallyResolved,
                  let status = raw.flatMap(WorkoutStatus.init(rawValue:)),
                  status == .completed || status == .skipped else { return }
            locallyResolved = true
            if autoAdvance, status == .completed, let next = nextUnfinishedLogID, let onAdvance {
                Self.log.info("remote completion of \(logID, privacy: .public) → flow hand-off to \(next, privacy: .public)")
                onAdvance(next)
            } else {
                Self.log.info("remote resolve of \(logID, privacy: .public) (\(status.rawValue, privacy: .public)) → dismiss")
                dismiss()
            }
        }
        .onAppear {
            guard !didRestorePage else { return }
            if startsResumed {
                // Resume on the page computed at init (with its wall-clock anchor). A
                // paged TabView can settle on page 0 on first layout, so re-assert the
                // resume page (now and once more after this run loop) before honoring
                // page changes — otherwise that snap would land on, and reset at, the
                // Ready page.
                jumpToResumePage()
                Task { @MainActor in
                    jumpToResumePage()
                    finishRestore()
                }
            } else if enteredViaFlow {
                // Auto-advanced into this exercise (flow mode): no Ready tap — begin the
                // exercise, then let finishRestore broadcast its first work frame so a
                // mirroring device follows across the exercise boundary (a discrete hand-off,
                // not a shared-timer auto-advance it could reach on its own).
                beginExercise()
                announceCue()
                finishRestore()
            } else {
                // Not-started opens on the Ready page.
                finishRestore()
            }
        }
        .onDisappear {
            // Leaving the flow (back / done) — stop the watch from following and dismiss the
            // Live Activity (it lingers briefly on a completed run before auto-dismissing).
            // Speech is *not* stopped here: this view is torn down and rebuilt on every
            // flow hand-off (`.id(currentLogID)`), so stopping would cut the incoming
            // exercise's cue. The run host (`RunFlowView`) silences narration when the
            // whole run leaves instead.
            onLiveEnded()
            onActivityEnded()
        }
    }

    /// Move to the resume page without animation, only if we're not already there
    /// (so a re-assert after a TabView snap-to-0 is a no-op in the common case).
    private func jumpToResumePage() {
        let target = resumeTargetPage ?? resumePage
        guard currentPage != target else { return }
        var transaction = Transaction()
        transaction.disablesAnimations = true
        withTransaction(transaction) { currentPage = target }
    }

    /// Where a resumed run re-opens: the page plus the wall-clock anchor that lets its
    /// timer continue from where the exercise actually is, rebuilt from the durable
    /// timestamps (there is no ephemeral state to restore — leaving the screen tears the
    /// live flow down).
    ///
    ///   • No sets recorded yet → the first work page, its stopwatch anchored to
    ///     `startedAt` so it keeps counting.
    ///   • k sets recorded and the rest after set k is still running (the k-th entry is
    ///     stamped as its rest begins — see `recordProgress`) → that rest page, anchored
    ///     to its true window so the countdown resumes mid-flight and still auto-advances
    ///     at the real boundary.
    ///   • Otherwise → set k+1's work page, anchored at the rest's computed end.
    ///
    /// Logs without the timestamps (older files) fall back to today's behavior: the
    /// first unfinished set's work page, self-anchored at local `now`.
    private func computeResume(now: Date = Date()) -> (page: Int, anchor: PageAnchor?) {
        let fallback = resumePage
        guard let log else { return (fallback, nil) }
        let completed = min(max(0, log.currentStateIndex), setCount - 1)

        func workAnchor(page: Int, start: Date) -> PageAnchor {
            PageAnchor(page: page, start: start,
                       end: isDuration ? start.addingTimeInterval(Double(workDurationSeconds)) : nil)
        }

        if completed == 0 {
            guard let started = log.startedAt else { return (fallback, nil) }
            // Clamp to `now` so a peer's clock skew can't stamp a future start and
            // make the stopwatch (or a timed set's window) run long.
            return (fallback, workAnchor(page: fallback, start: min(started, now)))
        }
        guard let entries = log.setEntries, entries.count >= completed else { return (fallback, nil) }
        let restStart = min(entries[completed - 1].completedAt, now)
        let restEnd = restStart.addingTimeInterval(Double(effectiveRest))
        if now < restEnd {
            let restPage = base + (completed - 1) * 2 + 1
            return (restPage, PageAnchor(page: restPage, start: restStart, end: restEnd))
        }
        return (fallback, workAnchor(page: fallback, start: restEnd))
    }

    // MARK: - Live mirror

    /// Finish the initial-page restore, then either follow an in-progress remote driver or, if
    /// we're the one starting the run, announce our position to the watch.
    private func finishRestore() {
        didRestorePage = true
        if let frame = incomingFrame, frame.logID == logID {
            applyIncoming(frame)
        } else {
            broadcastLive(for: currentPage)
        }
        // Seed the Live Activity with the opening page (a resumed run starts it here; a
        // not-started run sits on Ready, which the controller ignores until the run begins).
        emitActivity(for: currentPage)
    }

    /// Reflect the current flow position onto the Live Activity. Built from the same
    /// `liveSnapshot` the watch broadcast uses, but honoring the remote anchors when the page was
    /// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
    private func emitActivity(for page: Int) {
        guard var snapshot = liveSnapshot(for: page) else { return }
        if let anchor = pageAnchor, anchor.page == page {
            snapshot.phaseStart = anchor.start
            snapshot.phaseEnd = anchor.end
        }
        onActivity(snapshot)
    }

    /// Push the current flow position to the watch. The anchor is stamped *now* — the page
    /// just became active — except when the page carries an anchor (a resumed run continuing
    /// its phase), which is forwarded so the watch's mirror timer lines up with this device's.
    private func broadcastLive(for page: Int) {
        guard var snapshot = liveSnapshot(for: page) else { return }
        if let anchor = pageAnchor, anchor.page == page {
            snapshot.phaseStart = anchor.start
            snapshot.phaseEnd = anchor.end
        }
        onLive(snapshot)
    }

    /// Follow a transition the watch made: jump to the frame's page (matching its set count
    /// for a remote One More) without re-broadcasting or re-recording it — the watch owns the
    /// durable write, which arrives separately over the document sync.
    private func applyIncoming(_ frame: LiveProgress) {
        guard didRestorePage, frame.logID == logID,
              frame.isNewer(thanVersion: lastAppliedVersion, start: lastAppliedStart) else { return }
        lastAppliedVersion = frame.version
        lastAppliedStart = frame.phaseStart
        if frame.setCount != setCount { setCount = frame.setCount }
        let target = page(forPhase: frame.phase, setIndex: frame.setIndex)
        Self.log.info("apply frame v\(frame.version) \(frame.phase.rawValue, privacy: .public)/\(frame.setIndex) → page \(target) log=\(logID, privacy: .public)")
        // A followed frame is progress this screen now *knows*, so a durable echo of the
        // same transition can't re-trigger the repair jump (a remote reset re-baselines).
        knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
        // Anchor the target page's timer to the sender's wall clock, not local now, so the
        // displayed countdown matches regardless of how long the frame took to arrive.
        pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd)
        guard target != currentPage else { return }
        pageChangeCause = .remote
        withAnimation { currentPage = target }
    }

    /// Durable-state safety net for the ephemeral mirror. A peer's swipe frame can be lost
    /// (`sendMessage` is reachable-only and the retry can run dry), but its durable write still
    /// arrives via the document sync and is absorbed into `doc` by the parent. When the
    /// absorbed doc shows sets completed beyond anything this screen has recorded or
    /// followed, jump forward to the first unfinished set's work page so the flow can't stay
    /// stuck on a stale phase. Forward-only — progress is monotonic, so a same-or-behind doc
    /// is our own write echoing back — and only while the run is still in progress.
    private func repairFromDurable() {
        guard didRestorePage, let log,
              log.status == WorkoutStatus.inProgress.rawValue else { return }
        let completed = log.currentStateIndex
        guard completed > knownStateIndex else { return }
        knownStateIndex = completed
        if log.sets > setCount { setCount = log.sets }
        let target = base + min(max(0, completed), setCount - 1) * 2
        guard target > currentPage else { return }
        Self.log.info("durable repair: completed=\(completed) → page \(target) log=\(logID, privacy: .public)")
        clearAnchor()
        pageChangeCause = .remote
        withAnimation { currentPage = target }
    }

    /// Drop the page anchor when a human transition moves us off the anchored page, so the
    /// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
    private func clearAnchor() {
        pageAnchor = nil
    }

    /// Wall-clock plausibility gate for *automatic* completion of a duration exercise: its
    /// work windows alone take `sets × duration`, so an auto-fire earlier than that (minus
    /// slack) proves the terminal page was reached erroneously — a spurious pager
    /// write-back or a stale anchor, not a run that actually happened. Human Done taps are
    /// never gated; rep-based exercises have no wall-clock expectation and always pass, as
    /// does a log missing its `startedAt` stamp (nothing to judge against).
    private var autoCompletionPlausible: Bool {
        guard isDuration, let log, let started = log.startedAt else { return true }
        let minimum = Double(workDurationSeconds * setCount) - 2
        return Date().timeIntervalSince(started) >= minimum
    }

    /// The flow-terminal rest fired before the exercise could have run (see
    /// `autoCompletionPlausible`) — jump back to where the durable timestamps say the run
    /// actually is instead of durably completing a phase that never happened.
    private func repairEarlyTerminal() {
        let resume = computeResume()
        Self.log.warning("early terminal auto-fire blocked for \(logID, privacy: .public); repairing to page \(resume.page)")
        guard currentPage != resume.page else {
            pageAnchor = resume.anchor
            return
        }
        pageAnchor = resume.anchor
        pageChangeCause = .remote
        withAnimation { currentPage = resume.page }
    }

    /// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
    private func page(forPhase phase: LiveRunPhase, setIndex: Int) -> Int {
        let set = min(max(0, setIndex), max(0, setCount - 1))
        switch phase {
        case .ready:  return showsReady ? 0 : base
        case .work:   return base + set * 2
        case .rest:   return base + set * 2 + 1
        case .finish: return base + cycleCount
        }
    }

    /// Build the live-run frame for a given page: phase, the set it pertains to, and the
    /// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish)
    /// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
    private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? {
        guard let log else { return nil }

        func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
            LiveProgress(
                workoutID: doc.id,
                logID: logID,
                exerciseName: log.exerciseName,
                phase: phase,
                setIndex: setIndex,
                setCount: setCount,
                detail: detail,
                phaseStart: now,
                phaseEnd: end,
                version: 0
            )
        }

        if showsReady && page == 0 {
            return frame(.ready, setIndex: 0, end: nil)
        }
        let cycleIndex = page - base
        if cycleIndex == cycleCount {
            // In flow mode with a next exercise, the terminal page is a between-exercise
            // rest (mirrored as a `.rest` frame), not the Finish page.
            if autoAdvance && nextUnfinishedLogID != nil {
                return frame(.rest, setIndex: max(0, setCount - 1),
                             end: now.addingTimeInterval(Double(effectiveRest)))
            }
            return frame(.finish, setIndex: max(0, setCount - 1),
                         end: now.addingTimeInterval(Double(max(1, doneCountdownSeconds))))
        } else if cycleIndex.isMultiple(of: 2) {
            let set = cycleIndex / 2
            let end = isDuration ? now.addingTimeInterval(Double(workDurationSeconds)) : nil
            return frame(.work, setIndex: set, end: end)
        } else {
            let set = (cycleIndex - 1) / 2   // the rest follows this set
            return frame(.rest, setIndex: set, end: now.addingTimeInterval(Double(effectiveRest)))
        }
    }

    @ViewBuilder
    private func page(for index: Int) -> some View {
        let isActive = index == currentPage
        // Only the anchored page (remote frame or chained auto-advance) carries anchors;
        // every other page (reached by a human transition) counts off its own `now`.
        let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil
        let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil
        if showsReady && index == 0 {
            ReadyPhaseView(summary: readySummary, onStart: start)
        } else {
            let cycleIndex = index - base
            if cycleIndex == cycleCount {
                if autoAdvance, nextUnfinishedLogID != nil {
                    // Flow mode: the terminal page is a between-exercise rest that
                    // completes this exercise and hands off to the next when it ends.
                    // The header reads "Coming up" — the headline above already names
                    // the next exercise (it follows `figureExerciseName`).
                    CountdownPhaseView(
                        header: "Coming up",
                        tint: .restTimer,
                        seconds: effectiveRest,
                        isActive: isActive,
                        anchorStart: anchorStart,
                        anchorEnd: anchorEnd,
                        onActivate: announceComingUp,
                        onCountdownBeat: announceCountdownBeat
                    ) { _ in
                        // An auto-fire before the exercise could possibly have run proves
                        // this page was reached erroneously — repair, don't complete.
                        guard autoCompletionPlausible else { repairEarlyTerminal(); return }
                        completeExercise()
                        // Re-resolve at fire time — the next exercise may have been
                        // finished elsewhere while we rested. None left ⇒ end the run.
                        // No hand-off host (the live-mirror cover) ⇒ close instead of
                        // freezing on a spent countdown; a follower re-presents on the
                        // driver's next-exercise frame.
                        if let next = nextUnfinishedLogID, let onAdvance {
                            onAdvance(next)
                        } else {
                            dismiss()
                        }
                    }
                    .overlay(alignment: .bottom) { adjustPill }
                } else {
                    // Finish page — confirm Done (auto-fires) or add One More.
                    FinishPhaseView(
                        isActive: isActive,
                        onDone: { completeExercise(); dismiss() },
                        onOneMore: addSet,
                        anchorEnd: anchorEnd,
                        autoFireAllowed: { autoCompletionPlausible }
                    )
                    .overlay(alignment: .bottom) { adjustPill }
                }
            } else if cycleIndex.isMultiple(of: 2) {
                let setNumber = cycleIndex / 2 + 1
                if isDuration {
                    // Timed work set — count down from the planned duration, then
                    // auto-advance (and buzz) the same way a rest does.
                    CountdownPhaseView(
                        header: "\(setNumber) of \(setCount)",
                        tint: .workTimer,
                        seconds: workDurationSeconds,
                        isActive: isActive,
                        anchorStart: anchorStart,
                        anchorEnd: anchorEnd,
                        onCountdownBeat: announceWorkEndBeat
                    ) { end in
                        withAnimation { advance(from: index, phaseEndedAt: end) }
                    }
                } else {
                    // Rep-based work set — count up; the user swipes left when done.
                    WorkPhaseView(
                        setNumber: setNumber,
                        totalSets: setCount,
                        detail: detail,
                        isActive: isActive,
                        anchorStart: anchorStart
                    )
                }
            } else {
                // Rest phase. Auto-advances to the next work page when the timer hits zero.
                CountdownPhaseView(
                    header: "Rest",
                    tint: .restTimer,
                    seconds: effectiveRest,
                    isActive: isActive,
                    anchorStart: anchorStart,
                    anchorEnd: anchorEnd,
                    onCountdownBeat: announceCountdownBeat
                ) { end in
                    withAnimation { advance(from: index, phaseEndedAt: end) }
                }
                .overlay(alignment: .bottom) { adjustPill }
            }
        }
    }

    // MARK: - Live status panel

    /// Scales the panel's metric digits with Dynamic Type.
    @ScaledMetric(relativeTo: .title) private var metricFontSize: CGFloat = 34

    /// When the workout actually began: the earliest started exercise, else the stamp
    /// the workout was created with (a run still sitting on its first Ready page).
    private var workoutStartAnchor: Date {
        doc.logs.compactMap(\.startedAt).min() ?? doc.start
    }

    /// The status strip between the two halves: live heart rate (tinted by the target
    /// band, with the ease-off/push-harder cue, when the run carries a target), HR
    /// zone, and active calories — all watch-fed, dimmed to a placeholder until
    /// samples flow — plus the always-ticking workout stopwatch. A horizontal band in
    /// portrait, a vertical column in landscape: the *inverse* of `splitLayout`, so it
    /// always sits between the halves.
    @ViewBuilder
    private var statusPanel: some View {
        Group {
            if verticalSizeClass == .compact {
                VStack(spacing: 22) { statusMetrics(expand: false) }
                    .padding(.horizontal, 10)
                    .frame(maxHeight: .infinity)
            } else {
                HStack(spacing: 0) { statusMetrics(expand: true) }
                    .padding(.vertical, 8)
                    .padding(.horizontal, 12)
            }
        }
        .animation(.snappy(duration: 0.25), value: liveRun.heartRate)
        .animation(.snappy(duration: 0.25), value: liveRun.activeEnergyKcal)
        .animation(.snappy(duration: 0.25), value: liveRun.hrZone)
    }

    /// The four metric cells, in fixed order. `expand` spreads them evenly across the
    /// portrait band; the landscape column keeps intrinsic widths.
    @ViewBuilder
    private func statusMetrics(expand: Bool) -> some View {
        Group {
            heartRateMetric
            metricCell(liveRun.hrZone.map { Text(String($0)) }, caption: "ZONE")
            metricCell(liveRun.activeEnergyKcal.map { Text("\(Int($0.rounded()))") }, caption: "KCAL")
            metricCell(Text(workoutStartAnchor, style: .timer), caption: "ELAPSED")
        }
        .frame(maxWidth: expand ? .infinity : nil)
    }

    /// Heart rate, tinted by band with the correction cue — ease off (arrow down) or
    /// push harder (arrow up) — when the run carries a target; neutral otherwise, so
    /// the readout is still useful on a strength run with a watch session going.
    private var heartRateMetric: some View {
        let band = doc.targetHeartRate.flatMap { target in
            liveRun.heartRate.map { HeartRateBand(bpm: $0, target: target) }
        }
        return metricCell(
            liveRun.heartRate.map { Text("\(Int($0.rounded()))") },
            caption: "BPM",
            cue: band?.cueSymbol,
            tint: band?.tint ?? .primary)
    }

    /// One panel cell: big rounded digits over a small caption. A nil value renders as
    /// a dimmed placeholder so the layout never jumps as watch samples start flowing.
    private func metricCell(_ value: Text?, caption: String, cue: String? = nil, tint: Color = .primary) -> some View {
        VStack(spacing: 2) {
            HStack(spacing: 5) {
                (value ?? Text(verbatim: "–"))
                    .monospacedDigit()
                    .contentTransition(.numericText())
                if let cue {
                    Image(systemName: cue)
                        .font(.system(size: metricFontSize * 0.5, weight: .bold))
                }
            }
            .font(.system(size: metricFontSize, weight: .bold, design: .rounded))
            .foregroundStyle(value == nil ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint))
            .lineLimit(1)
            .minimumScaleFactor(0.6)

            Text(caption)
                .font(.caption.weight(.semibold))
                .foregroundStyle(.secondary)
        }
    }

    // MARK: - Adjust the last set's entry

    /// The just-completed set's recorded entry, shown on the rest and finish pages
    /// as a tappable pill — the log-by-default record with a correct-by-exception
    /// affordance. Dead time by design: it never blocks the countdown or auto-advance.
    @ViewBuilder
    private var adjustPill: some View {
        if let entry = log?.setEntries?.last {
            Button {
                showingAdjust = true
            } label: {
                HStack(spacing: 6) {
                    Text(SetEntryFormat.summary(entry, weightUnit: weightUnit))
                    Image(systemName: "pencil")
                        .font(.caption)
                }
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .padding(.horizontal, 14)
                .padding(.vertical, 7)
                .background(.quaternary.opacity(0.5), in: Capsule())
            }
            .buttonStyle(.plain)
            .padding(.bottom, 32)   // clear the phase-dot row overlaid at the bottom
            .sheet(isPresented: $showingAdjust) { adjustSheet }
        }
    }

    @ViewBuilder
    private var adjustSheet: some View {
        if let log, let entry = log.setEntries?.last {
            SetEntryAdjustSheet(
                entry: entry,
                setNumber: log.setEntries?.count ?? 1,
                loadType: LoadType(rawValue: log.loadType) ?? .weight,
                weightUnit: weightUnit,
                onChange: updateLastEntry
            )
            .presentationDetents([.height(300)])
            .presentationDragIndicator(.visible)
        }
    }

    /// Write an adjusted value back onto the last recorded entry and persist.
    private func updateLastEntry(_ entry: SetEntry) {
        guard let i = doc.logs.firstIndex(where: { $0.id == logID }),
              let last = doc.logs[i].setEntries?.indices.last else { return }
        doc.logs[i].setEntries?[last] = entry
        doc.logs[i].touch()
        doc.updatedAt = Date()
        onChange()
    }

    // MARK: - Mutations

    /// Leave the Ready page for the first work phase, marking the exercise started.
    private func start() {
        beginExercise()
        announceCue()
        withAnimation { currentPage = base }
    }

    /// True when narration is on and the chosen style speaks the concise coming-up/countdown
    /// cues. Also requires a wired announcer (nil in the screenshot host).
    private var speaksCues: Bool {
        speakExerciseCues && spokenCueStyle.speaksCues && speechAnnouncer != nil
    }

    /// Speak the exercise's setup/form cues when it starts, if the user opted into a style
    /// that reads them. Silent when the announcer isn't wired (screenshot host) or the
    /// exercise has no bundled info.
    private func announceCue() {
        guard speakExerciseCues, spokenCueStyle.readsInstructions,
              let announcer = speechAnnouncer, let log else { return }
        guard let info = ExerciseInfoLibrary.info(for: libraryIdentity(log)) else { return }
        announcer.speak(info.spokenScript(name: log.exerciseName, brief: true))
    }

    /// Announce the exercise coming up after a between-exercise rest ("Coming up, Pull Ups").
    /// Spoken as the rest begins, so it lands well before the next exercise starts.
    private func announceComingUp() {
        guard speaksCues, let announcer = speechAnnouncer, let next = nextExerciseName else { return }
        announcer.speak("Coming up. \(next).")
    }

    /// Count into the next work phase, one word per second synced to the digits the countdown
    /// displays: the upcoming exercise's name as it shows 6 — with the 5s and 4s beats left
    /// silent so even a long name finishes — then "in three" (3) → "two" → "one" → "Go!" as
    /// the display reaches zero. Every beat but "Go" holds the audio session so background
    /// music doesn't pulse between them; "Go" releases it, and (like any cue) plays to
    /// completion even if the rest ends mid-word. `remaining` is the displayed digit.
    private func announceCountdownBeat(_ remaining: Int) {
        guard speaksCues, let announcer = speechAnnouncer else { return }
        switch remaining {
        case 6:
            // The exercise about to be worked: the next one across a between-exercise rest,
            // otherwise this one. Skipped when unknown (never blocks the count that follows).
            let name = figureExerciseName
            if !name.isEmpty { announcer.speak(name + ".", holdSession: true) }
        case 5, 4: break   // buffer beats — let the exercise name play out before the count
        case 3: announcer.speak("In three.", holdSession: true)
        case 2: announcer.speak("Two.", holdSession: true)
        case 1: announcer.speak("One.", holdSession: true)
        default: announcer.speak("Go!")   // display at zero — release the duck for the work phase
        }
    }

    /// Count a timed work set out, mirroring the count-in: "<Exercise> ends." as the display
    /// shows 6 (5 and 4 are its silent buffer), then "in three" → "two" → "one" — and silence
    /// at zero, where the rest phase's own buzz marks the boundary. "One" is the last word,
    /// so it releases the audio duck instead of holding it.
    private func announceWorkEndBeat(_ remaining: Int) {
        guard speaksCues, let announcer = speechAnnouncer else { return }
        switch remaining {
        case 6:
            if let name = log?.exerciseName, !name.isEmpty {
                announcer.speak("\(name) ends.", holdSession: true)
            }
        case 3: announcer.speak("In three.", holdSession: true)
        case 2: announcer.speak("Two.", holdSession: true)
        case 1: announcer.speak("One.")
        default: break   // 5–4 are the name's buffer beats; 0 stays silent
        }
    }

    /// Programmatically move one page right when a countdown phase ends, guarding against
    /// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
    /// records progress but doesn't broadcast it (the watch auto-advances too).
    ///
    /// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then,
    /// not when this tick finally got runtime. Anchoring the next page there means a device
    /// that slept through the boundary (throttled wrist-down ticker) shows the same timer
    /// as one that crossed it on time — and a stack of missed boundaries fast-forwards
    /// itself, since each chained countdown lands already-elapsed and advances on its own
    /// next tick.
    private func advance(from index: Int, phaseEndedAt end: Date) {
        guard currentPage == index, index + 1 < totalPages else { return }
        pageAnchor = PageAnchor(page: index + 1, start: end,
                                end: liveSnapshot(for: index + 1, at: end)?.phaseEnd)
        pageChangeCause = .auto
        currentPage = index + 1
    }

    /// Flip a not-yet-started exercise to in-progress the moment the user taps Start.
    private func beginExercise() {
        guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
        guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
        doc.logs[i].transition(to: .inProgress)
        recomputeWorkoutStatus()
        doc.updatedAt = Date()
        onChange()
    }

    /// Append a bonus set from the Finish page: mark every prior set done, grow the
    /// plan, and slide forward into the bonus set's work phase.
    private func addSet() {
        let newCount = setCount + 1
        knownStateIndex = max(knownStateIndex, newCount - 1)

        if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
            doc.logs[i].sets = newCount
            doc.logs[i].currentStateIndex = newCount - 1   // every prior set is now complete
            // The old final set just became a completed *prior* set — record its entry.
            // The bonus set's own entry lands when it completes, not now.
            doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date())
            doc.logs[i].transition(to: .inProgress)
            recomputeWorkoutStatus()
            doc.updatedAt = Date()
            onChange()
        }

        withAnimation {
            setCount = newCount
            currentPage += 1
        }
    }

    /// Map a page to completed-set count and record forward progress.
    ///
    /// Paging tops out at `setCount − 1` completed sets — the final set is marked done
    /// only by an explicit **Done** (`completeExercise`). Progress is **monotonic**:
    /// completing a work phase advances the count, but swiping back — or a transient
    /// TabView snap to page 0 — never un-counts a set.
    private func recordProgress(for pageIndex: Int) {
        if showsReady && pageIndex == 0 { return }   // Ready page records nothing
        let reached = completedSets(for: pageIndex)
        knownStateIndex = max(knownStateIndex, reached)

        guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
        guard reached > doc.logs[i].currentStateIndex else { return }

        doc.logs[i].currentStateIndex = reached
        // Log-by-default: each newly-completed set gets a plan-filled entry
        // (append-only — swiping back never removes one; adjust corrects the last).
        doc.logs[i].fillSetEntries(upTo: reached, at: Date())
        doc.logs[i].transition(to: .inProgress)

        recomputeWorkoutStatus()
        doc.updatedAt = Date()
        onChange()
    }

    /// Swiping back to the **Ready?** page starts the exercise over from scratch.
    private func resetExercise() {
        guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
        let log = doc.logs[i]
        knownStateIndex = 0
        // Skip the write if it's already pristine (e.g. landing on Ready before any set).
        guard log.currentStateIndex != 0
                || log.status != WorkoutStatus.notStarted.rawValue else { return }
        doc.logs[i].currentStateIndex = 0
        doc.logs[i].transition(to: .notStarted)
        recomputeWorkoutStatus()
        doc.updatedAt = Date()
        onChange()
    }

    private func completeExercise() {
        locallyResolved = true
        knownStateIndex = max(knownStateIndex, setCount)
        guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
        doc.logs[i].currentStateIndex = setCount
        doc.logs[i].transition(to: .completed)

        recomputeWorkoutStatus()
        doc.updatedAt = Date()
        onChange()
    }

    private func recomputeWorkoutStatus() {
        doc.recomputeStatusFromLogs()
    }

    // MARK: - Formatting

    nonisolated static func durationLabel(_ seconds: Int) -> String {
        let mins = seconds / 60
        let secs = seconds % 60
        if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" }
        if mins > 0 { return "\(mins) min" }
        return "\(secs) sec"
    }
}

// MARK: - Set entry formatting

/// Renders recorded `SetEntry` actuals for display (the pill, the Completed page).
/// Which fields an entry carries follows the log's `LoadType`, so formatting keys
/// off the populated fields rather than re-deriving the type.
enum SetEntryFormat {
    /// One entry: "10 reps · 45 lb", "10 reps", or "45 sec".
    static func summary(_ entry: SetEntry, weightUnit: WeightUnit) -> String {
        var parts: [String] = []
        if let reps = entry.reps { parts.append("\(reps) reps") }
        if let weight = entry.weight { parts.append(weightUnit.format(weight)) }
        if let seconds = entry.seconds { parts.append(ExerciseProgressView.durationLabel(seconds)) }
        return parts.joined(separator: " · ")
    }

    /// All of a log's entries on one line: "4 × 10 reps @ 45 lb" when every set
    /// matched, "10 · 8 · 6 reps @ 45 lb" when reps vary but the weight is uniform,
    /// a per-set "10 × 45 lb · 8 × 42.5 lb" list when the weight varies too,
    /// "3 × 45 sec" / "45 · 45 · 30 sec" for durations, and the same for bodyweight.
    static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
        guard !entries.isEmpty else { return "" }
        if entries.contains(where: { $0.seconds != nil }) {
            return collapsedList(entries.map { $0.seconds ?? 0 }) + " sec"
        }
        let repsList = collapsedList(entries.map { $0.reps ?? 0 })
        let weights = entries.compactMap(\.weight)
        guard !weights.isEmpty else { return repsList + " reps" }
        if Set(weights).count == 1 {
            return "\(repsList) reps @ \(weightUnit.format(weights[0]))"
        }
        return entries.map { entry in
            guard let weight = entry.weight else { return "\(entry.reps ?? 0)" }
            return "\(entry.reps ?? 0) × \(weightUnit.format(weight))"
        }.joined(separator: " · ")
    }

    /// A per-set value list, collapsed to "N × v" when every set matches (so four
    /// tens read as "4 × 10", not "10 · 10 · 10 · 10") and left as a "10 · 8 · 6"
    /// list otherwise. A single set is just its value (never "1 × v").
    private static func collapsedList(_ values: [Int]) -> String {
        guard let first = values.first else { return "" }
        if values.count > 1 && values.allSatisfy({ $0 == first }) {
            return "\(values.count) × \(first)"
        }
        return values.map(String.init).joined(separator: " · ")
    }
}

// MARK: - Set entry adjust sheet

/// Compact steppers for correcting the just-completed set's entry — reps and
/// weight for a weighted set, reps alone for bodyweight, seconds for a timed
/// hold. Changes apply live (via `onChange`), so a swipe-down needs no confirm.
private struct SetEntryAdjustSheet: View {
    let setNumber: Int
    let loadType: LoadType
    let weightUnit: WeightUnit
    let onChange: (SetEntry) -> Void

    @State private var entry: SetEntry

    init(entry: SetEntry, setNumber: Int, loadType: LoadType, weightUnit: WeightUnit,
         onChange: @escaping (SetEntry) -> Void) {
        _entry = State(initialValue: entry)
        self.setNumber = setNumber
        self.loadType = loadType
        self.weightUnit = weightUnit
        self.onChange = onChange
    }

    /// Plate-math step in the *display* unit (the stored value stays unit-less).
    private var weightStep: Double { weightUnit == .kg ? 1.25 : 2.5 }

    var body: some View {
        VStack(spacing: 24) {
            Text("Set \(setNumber)")
                .font(.headline)
                .foregroundStyle(.secondary)

            switch loadType {
            case .weight:
                repsStepper
                weightStepper
            case .none:
                repsStepper
            case .duration:
                secondsStepper
            }
        }
        .padding(28)
        .frame(maxHeight: .infinity, alignment: .top)
        .onChange(of: entry) { _, updated in onChange(updated) }
    }

    private var repsStepper: some View {
        Stepper(value: Binding(get: { entry.reps ?? 0 }, set: { entry.reps = $0 }),
                in: 0...200) {
            Text("\(entry.reps ?? 0) reps")
                .font(.title3.weight(.semibold))
                .monospacedDigit()
        }
    }

    private var weightStepper: some View {
        Stepper(value: Binding(get: { entry.weight ?? 0 }, set: { entry.weight = $0 }),
                in: 0...2000, step: weightStep) {
            Text(weightUnit.format(entry.weight ?? 0))
                .font(.title3.weight(.semibold))
                .monospacedDigit()
        }
    }

    private var secondsStepper: some View {
        Stepper(value: Binding(get: { entry.seconds ?? 0 }, set: { entry.seconds = $0 }),
                in: 0...3600, step: 5) {
            Text(ExerciseProgressView.durationLabel(entry.seconds ?? 0))
                .font(.title3.weight(.semibold))
                .monospacedDigit()
        }
    }
}

// MARK: - Haptics

/// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone
/// flow buzzes at the same beats (set start, countdown ping, rest end, done).
private enum WorkoutHaptic {
    case start, tick, stop, success

    @MainActor
    func play() {
        switch self {
        case .start:
            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
        case .tick:
            UIImpactFeedbackGenerator(style: .light).impactOccurred()
        case .stop:
            UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
        case .success:
            UINotificationFeedbackGenerator().notificationOccurred(.success)
        }
    }
}

// MARK: - Heart-rate band presentation

private extension HeartRateBand {
    /// The correction cue: below target → push harder (up), above → ease off (down).
    var cueSymbol: String {
        switch self {
        case .low: "arrow.up"
        case .inRange: "checkmark"
        case .high: "arrow.down"
        }
    }

    var tint: Color {
        switch self {
        case .low: .blue
        case .inRange: .green
        case .high: .orange
        }
    }
}

// MARK: - Phase Colors

private extension Color {
    /// Count-up work tint — brand purple, brightened in dark mode for contrast on
    /// black and deepened in light mode for contrast on white.
    static let workTimer = Color(UIColor { traits in
        traits.userInterfaceStyle == .dark
            ? UIColor(red: 0.66, green: 0.45, blue: 0.96, alpha: 1)
            : UIColor(red: 0.45, green: 0.18, blue: 0.78, alpha: 1)
    })

    /// Count-down rest tint — a light gray that deepens in light mode so it stays
    /// legible on a white background.
    static let restTimer = Color(UIColor { traits in
        traits.userInterfaceStyle == .dark
            ? UIColor(white: 0.74, alpha: 1)
            : UIColor(white: 0.52, alpha: 1)
    })
}

// MARK: - Phase Timer Layout

/// Shared skeleton for the work and rest pages so their timers use an identical font
/// and land at exactly the same spot: a header line, the big timer, then a footer line.
/// The header and footer live in *equal flexible bands* above and below the digits, so
/// the timer sits at the exact vertical center of the half on every page — whatever the
/// bands hold (or don't) can never nudge it.
private struct PhaseTimerLayout<Content: View>: View {
    let header: String
    let footer: String
    let tint: Color
    @ViewBuilder var timer: Content

    /// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor`
    /// so the largest sizes never overflow the top half).
    @ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108

    var body: some View {
        VStack(spacing: 0) {
            Text(header)
                .font(.title3)
                .foregroundStyle(.secondary)
                .padding(.bottom, 10)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)

            timer
                .font(.system(size: timerFontSize, weight: .bold, design: .rounded))
                .monospacedDigit()
                .foregroundStyle(tint)
                .lineLimit(1)
                .minimumScaleFactor(0.5)

            Text(footer.isEmpty ? " " : footer)
                .font(.title3)
                .foregroundStyle(.secondary)
                .padding(.top, 10)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Timer Format

/// Renders the big phase counters — the same "m:ss" (or "h:mm:ss") shape
/// `Text(timerInterval:)` used to draw, now built from state-driven whole seconds
/// so the digits can roll through `.numericText` transitions.
private enum TimerFormat {
    static func clock(_ seconds: Int) -> String {
        let s = max(0, seconds)
        if s >= 3600 {
            return String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60)
        }
        return String(format: "%d:%02d", s / 60, s % 60)
    }
}

// MARK: - Work Phase Dots

/// Progress row with one marker per work set. The set being worked is drawn as a wider
/// dash; during a rest every marker is a plain dot and the gap straddling that rest
/// grows to about double, hinting at the pause between the set that just ended and the
/// next one. Completed sets are full strength, upcoming ones dimmed.
private struct WorkPhaseDots: View {
    struct Model: Equatable {
        let setCount: Int
        /// The set currently being worked — drawn as a dash. `nil` during a rest.
        let activeSet: Int?
        /// The set just completed; the gap *after* its dot doubles. `nil` during work.
        let restAfterSet: Int?
        /// How many sets are fully done (for dimming the upcoming ones).
        let completed: Int
    }

    let model: Model

    // Geometry — tune freely.
    private let dotWidth: CGFloat = 8
    private let dashWidth: CGFloat = 20
    private let markerHeight: CGFloat = 8
    private let gap: CGFloat = 8
    private var restGap: CGFloat { gap * 2 }

    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<model.setCount, id: \.self) { i in
                marker(for: i)
                if i < model.setCount - 1 {
                    Color.clear.frame(width: gapWidth(after: i), height: markerHeight)
                }
            }
        }
        .animation(.easeInOut(duration: 0.3), value: model)
    }

    private func marker(for i: Int) -> some View {
        let isActive = model.activeSet == i
        let isDone = i < model.completed
        return Capsule()
            .fill(Color.workTimer)
            .frame(width: isActive ? dashWidth : dotWidth, height: markerHeight)
            .opacity(isActive || isDone ? 1 : 0.45)
    }

    private func gapWidth(after i: Int) -> CGFloat {
        model.restAfterSet == i ? restGap : gap
    }
}

// MARK: - Phase Button Styling

private extension View {
    /// Chunky, rounded, heavy treatment shared by the Start / Done / One More buttons:
    /// a plump label (echoing the counter digits) over a taller full-width body.
    func phaseButtonLabel() -> some View {
        self
            .font(.system(.title2, design: .rounded, weight: .heavy))
            .frame(maxWidth: .infinity)
            .padding(.vertical, 14)
    }
}

// MARK: - Completed Phase

/// Shown instead of the run flow when the exercise was already completed on open:
/// the badge, then when it was completed, what was done, and how long it took
/// (the timing lines appear only on logs that carry the timestamps).
private struct CompletedPhaseView: View {
    let log: WorkoutLogDocument?

    @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb

    /// Scales the completion badge with Dynamic Type.
    @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96

    /// "4 sets × 12 reps" / "3 sets × 45 sec".
    private var planSummary: String {
        guard let log else { return "" }
        let setsText = "\(log.sets) set\(log.sets == 1 ? "" : "s")"
        if LoadType(rawValue: log.loadType) == .duration {
            return "\(setsText) × \(ExerciseProgressView.durationLabel(log.durationSeconds))"
        }
        return "\(setsText) × \(log.reps) reps"
    }

    /// Start-to-done wall time, when both timestamps were recorded.
    private var elapsed: String? {
        guard let log, let start = log.startedAt, let end = log.completedAt else { return nil }
        let seconds = Int(end.timeIntervalSince(start))
        guard seconds > 0 else { return nil }
        return ExerciseProgressView.durationLabel(seconds)
    }

    var body: some View {
        VStack(spacing: 14) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: badgeSize, weight: .bold))
                .foregroundStyle(Color.accentColor)
            Text("Completed")
                .font(.system(.title, design: .rounded, weight: .heavy))
                .foregroundStyle(Color.accentColor)

            if let log {
                VStack(spacing: 4) {
                    if let completedAt = log.completedAt {
                        Text(completedAt.formattedRelativeDateTime())
                    }
                    // Recorded actuals ("10 · 8 · 6 reps @ 45 lb") beat the plan
                    // summary; legacy logs without entries keep showing the plan.
                    if let entries = log.setEntries, !entries.isEmpty {
                        Text(SetEntryFormat.actualsLine(entries, weightUnit: weightUnit))
                    } else {
                        Text(planSummary)
                    }
                    if let elapsed {
                        Text("in \(elapsed)")
                    }
                }
                .font(.callout)
                .foregroundStyle(.secondary)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Skipped Phase

/// Shown instead of the run flow when the exercise was skipped (the workout was
/// ended early with this one unfinished). Same badge treatment as Completed,
/// in gray, matching the list row's skipped icon.
private struct SkippedPhaseView: View {
    /// Scales the skipped badge with Dynamic Type.
    @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96

    var body: some View {
        VStack(spacing: 14) {
            Image(systemName: "wrongwaysign")
                .font(.system(size: badgeSize, weight: .bold))
                .foregroundStyle(.gray)
            Text("Skipped")
                .font(.system(.title, design: .rounded, weight: .heavy))
                .foregroundStyle(.gray)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Ready Phase

private struct ReadyPhaseView: View {
    let summary: String
    let onStart: () -> Void

    /// Scales the "Ready?" headline with Dynamic Type.
    @ScaledMetric(relativeTo: .largeTitle) private var readyFontSize: CGFloat = 44

    var body: some View {
        VStack(spacing: 14) {
            Text("Ready?")
                .font(.system(size: readyFontSize, weight: .bold, design: .rounded))
                .lineLimit(1)
                .minimumScaleFactor(0.5)

            if !summary.isEmpty {
                Text(summary)
                    .font(.title3)
                    .foregroundStyle(.secondary)
            }

            Button(action: onStart) {
                Text("Start")
                    .phaseButtonLabel()
            }
            .buttonStyle(.borderedProminent)
            .tint(.workTimer)
            .buttonBorderShape(.capsule)
            .padding(.top, 8)
            .padding(.horizontal, 40)
        }
        .padding(.horizontal)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Work Phase

private struct WorkPhaseView: View {
    let setNumber: Int
    let totalSets: Int
    let detail: String
    let isActive: Bool
    /// When the page was reached by a remote frame, anchor the count-up to the sender's
    /// wall clock instead of local `now`, so the mirror lines up regardless of delivery lag.
    var anchorStart: Date? = nil

    /// Wall-clock anchor for the count-up stopwatch. The displayed seconds are always
    /// re-derived from this anchor on each tick (never incremented), so a delayed or
    /// slept-through tick can't drift the clock.
    @State private var startDate = Date()
    /// Whole seconds on display, stepped with an animation so the digits roll.
    @State private var elapsedSeconds = 0
    private let ticker = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()

    var body: some View {
        PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer) {
            Text(TimerFormat.clock(elapsedSeconds))
                .contentTransition(.numericText(countsDown: false))
        }
        .onAppear { if isActive { restart(haptic: true) } }
        .onChange(of: isActive) { _, active in if active { restart(haptic: true) } }
        // A later frame for this same page re-anchors the timer without re-buzzing.
        .onChange(of: anchorStart) { _, _ in if isActive { restart(haptic: false) } }
        .onReceive(ticker) { _ in tick() }
    }

    private func restart(haptic: Bool) {
        startDate = anchorStart ?? Date()
        elapsedSeconds = max(0, Int(Date().timeIntervalSince(startDate)))
        if haptic { WorkoutHaptic.start.play() }
    }

    private func tick() {
        guard isActive else { return }
        let elapsed = max(0, Int(Date().timeIntervalSince(startDate)))
        guard elapsed != elapsedSeconds else { return }
        withAnimation(.snappy(duration: 0.25)) { elapsedSeconds = elapsed }
    }
}

// MARK: - Countdown Phase

/// A count-down phase used for both rests and timed work sets: counts down from
/// `seconds`, pings once per second in the final three, then buzzes and auto-advances at
/// zero. The header/tint distinguish the two uses (purple "N of M" work vs. gray "Rest").
private struct CountdownPhaseView: View {
    let header: String
    var footer: String = ""
    let tint: Color
    let seconds: Int
    let isActive: Bool
    /// When the page was reached by a remote frame, anchor the countdown window to the
    /// sender's wall clock (`anchorStart`…`anchorEnd`) instead of local `now`…`now+seconds`,
    /// so the remaining time — and the auto-advance at zero — line up across both devices.
    var anchorStart: Date? = nil
    var anchorEnd: Date? = nil
    /// Fired once when the countdown genuinely begins (not a slept-through catch-up) — the
    /// spoken "Coming up …" cue hangs off this.
    var onActivate: (() -> Void)? = nil
    /// Fired once per second through the final beats — the digit the countdown display
    /// currently shows (6 for the exercise name, 5–4 as its buffer, then 3, 2, 1, and 0 as
    /// the display reaches zero) — so a spoken count ("Torso Twist… in three… two… one…
    /// Go!") names the number on screen, in step with the haptics. Nil skips it.
    var onCountdownBeat: ((Int) -> Void)? = nil
    /// Invoked once the countdown reaches zero (auto-advance to the next page), passing the
    /// phase's *computed* end so the next phase can anchor at the boundary itself — not at
    /// whenever this tick got runtime (a device asleep at the boundary ticks late).
    let onFinished: (Date) -> Void

    /// Wall-clock window for the countdown. The displayed seconds, haptics, and
    /// auto-advance below are all re-derived from `endDate` on each tick rather than
    /// decremented, so they stay correct even if a tick is delayed.
    @State private var startDate = Date()
    @State private var endDate = Date()
    /// Whole seconds on display, stepped with an animation so the digits roll.
    @State private var remainingSeconds = 0
    /// Lowest remaining-second we've already pinged, so a burst of catch-up ticks doesn't
    /// double-buzz.
    @State private var lastPingSecond = Int.max
    /// Guards the auto-advance so it fires exactly once even if ticks pile up.
    @State private var didFinish = false
    /// Sub-second ticks keep the displayed digit, the beats, and the auto-advance all
    /// within a tenth of the true wall-clock boundary.
    private let ticker = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()

    var body: some View {
        PhaseTimerLayout(header: header, footer: footer, tint: tint) {
            Text(TimerFormat.clock(remainingSeconds))
                .contentTransition(.numericText(countsDown: true))
        }
        .onAppear { if isActive { start(haptic: true) } }
        .onChange(of: isActive) { _, active in if active { start(haptic: true) } }
        // A later frame for this same page re-anchors the window without re-buzzing.
        .onChange(of: anchorEnd) { _, _ in if isActive { start(haptic: false) } }
        .onReceive(ticker) { _ in tick() }
    }

    private func start(haptic: Bool) {
        startDate = anchorStart ?? Date()
        endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
        remainingSeconds = max(0, Int(endDate.timeIntervalSinceNow))
        lastPingSecond = Int.max
        didFinish = false
        // No buzz — or spoken cue — for a chained catch-up page whose whole window already
        // elapsed while the device slept; it advances again on its next tick.
        if haptic, endDate > Date() {
            WorkoutHaptic.start.play()
            onActivate?()
        }
    }

    private func tick() {
        guard isActive, !didFinish else { return }
        let timeLeft = endDate.timeIntervalSinceNow

        if timeLeft <= 0 {
            didFinish = true
            if remainingSeconds != 0 {
                withAnimation(.snappy(duration: 0.25)) { remainingSeconds = 0 }
            }
            // Buzz the boundary only when it just happened — fast-forwarding through
            // boundaries that passed while the device slept stays silent.
            if Date().timeIntervalSince(endDate) < 3 {
                WorkoutHaptic.stop.play()
                // A missed final tick means the 0 beat ("Go!") never fired — deliver it here.
                if lastPingSecond > 0 { onCountdownBeat?(0) }
            }
            onFinished(endDate)
        } else {
            // Floor, not ceil: the display reads 0:00 through the whole last second, so
            // keying the beats to the same floored value makes each spoken word name the
            // digit on screen. Beat 0 lands as the display hits zero, up to a second
            // before the actual phase boundary.
            let displayed = Int(timeLeft)
            if displayed != remainingSeconds {
                withAnimation(.snappy(duration: 0.25)) { remainingSeconds = displayed }
            }
            if displayed <= 6 && displayed < lastPingSecond {
                lastPingSecond = displayed
                if displayed <= 2 { WorkoutHaptic.tick.play() }
                onCountdownBeat?(displayed)
            }
        }
    }
}

// MARK: - Finish Phase

/// Terminal page after the last set. **Done** completes the exercise — and fires
/// automatically after a configurable countdown so the user doesn't have to tap with
/// sweaty hands. **One More** appends a bonus set instead.
private struct FinishPhaseView: View {
    let isActive: Bool
    let onDone: () -> Void
    let onOneMore: () -> Void
    /// When the page was reached by a remote frame, anchor the auto-Done deadline to the
    /// sender's wall clock instead of local `now + countdown`, so both devices fire together.
    var anchorEnd: Date? = nil

    /// Gates the *automatic* Done only (the tap always works): when false, the countdown
    /// holds at zero awaiting a human — the host's wall-clock plausibility check says the
    /// exercise can't genuinely be finished yet (see `autoCompletionPlausible`).
    var autoFireAllowed: () -> Bool = { true }

    @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5

    /// Wall-clock deadline for the auto-Done. `remaining` is what the Done button shows.
    @State private var endDate = Date()
    @State private var remaining = 0
    /// Fires the auto-Done exactly once, and latches off while the page isn't active.
    @State private var didFire = false
    private let ticker = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()

    var body: some View {
        VStack(spacing: 14) {
            Button(action: fire) {
                Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
                    .phaseButtonLabel()
                    .contentTransition(.numericText(countsDown: true))
            }
            .buttonStyle(.borderedProminent)
            .tint(Color.workTimer)
            .buttonBorderShape(.capsule)

            Button(action: onOneMore) {
                Label("One More", systemImage: "plus")
                    .phaseButtonLabel()
            }
            .buttonStyle(.bordered)
            .buttonBorderShape(.capsule)
        }
        .padding(.horizontal, 40)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .onAppear { if isActive { start() } }
        .onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
        .onChange(of: anchorEnd) { _, _ in if isActive { start() } }
        .onReceive(ticker) { _ in tick() }
    }

    private func start() {
        endDate = anchorEnd ?? Date().addingTimeInterval(Double(max(1, doneCountdownSeconds)))
        remaining = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
        didFire = false
    }

    private func tick() {
        guard isActive, !didFire else { return }
        let r = Int(ceil(endDate.timeIntervalSinceNow))
        if max(0, r) != remaining {
            withAnimation(.snappy(duration: 0.25)) { remaining = max(0, r) }
        }
        if r <= 0, autoFireAllowed() { fire() }
    }

    private func fire() {
        guard !didFire else { return }
        didFire = true
        WorkoutHaptic.success.play()
        onDone()
    }
}
