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

import os
import SwiftUI
import WatchKit

/// Runs a single exercise as a horizontally-paged flow, mirroring the iPhone's:
///
///   [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). Rep-based **work** phases count
/// *up* (brand purple) and the user swipes on when done; **timed** work phases and
/// **rests** count *down* (rest in gray), ping once per second in the final three
/// seconds, then auto-advance. Sliding past the final set reaches a **Finish** page with
/// **One More** and an auto-firing **Done**. Swiping all the way back to Ready resets the
/// run from scratch.
///
/// A dot row tracks progress with one marker per work set — the active set drawn as a
/// wider dash, with the gap widening at the rest you're currently in.
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

    /// Live HR straight off the session's builder — drives the target-HR readout when
    /// this run carries a `targetHeartRate`. Optional so hosts without a session
    /// manager in the environment (the screenshot root) render without it.
    @Environment(WorkoutSessionManager.self) private var sessionManager: WorkoutSessionManager?
    @Environment(\.isLuminanceReduced) private var dimmed

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

    /// Broadcasts the current flow position so a propped-up iPhone can mirror the run 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 mirror reaches it independently off the same wall-clock anchors.
    let onLive: (LiveProgress) -> Void
    let onLiveEnded: () -> 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.
    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.
    var onAdvance: ((String) -> Void)? = nil

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

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

    /// Auto-Done countdown on the Finish page — read so the mirror can show the same timer.
    @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5

    /// 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 showingCancelConfirm = false
    @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. 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. Without it, opening a
    /// skipped log dropped into the live flow, whose first recorded set would write the
    /// log back to in-progress — resurrecting a resolved run through the merge.
    @State private var startsSkipped: Bool

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

    /// Where the resume landed, computed once at init (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?

    /// Forces the starting page (used only by the DEBUG screenshot host). When set it
    /// also suppresses the Ready page so the index is a plain work/rest cycle offset.
    private let debugInitialPage: Int?

    init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil, enteredViaFlow: Bool = false, onAdvance: ((String) -> Void)? = nil, debugInitialPage: Int? = nil) {
        self._doc = doc
        self.logID = logID
        self.onChange = onChange
        self.onLive = onLive
        self.onLiveEnded = onLiveEnded
        self.enteredViaFlow = enteredViaFlow
        self.onAdvance = onAdvance
        self.incomingFrame = incomingFrame
        self.debugInitialPage = debugInitialPage

        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))

        let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
        // The Ready page always leads the flow (except in the screenshot host, or a
        // flow-advanced entry which begins immediately). 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 ready = debugInitialPage == nil && !enteredViaFlow
        _startsResumed = State(initialValue: ready && !notStarted)
        _startsCompleted = State(initialValue: ready && log?.status == WorkoutStatus.completed.rawValue)
        _startsSkipped = State(initialValue: ready && log?.status == WorkoutStatus.skipped.rawValue)

        let base = ready ? 1 : 0
        // 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: debugInitialPage ?? (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 ready, !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 always leads the flow, so a resumed run can swipe back to it
    /// (which resets the exercise). Suppressed for the screenshot host (which pins an
    /// explicit page) and for a flow-advanced entry (which begins immediately). Derived
    /// from immutable inputs, so it stays stable for the life of the screen — the
    /// page-index math below depends on it.
    private var showsReady: Bool { debugInitialPage == nil && !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
    }

    /// 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)"
    }

    var body: some View {
        if startsCompleted {
            CompletedPhaseView()
        } else if startsSkipped {
            SkippedPhaseView()
        } else {
            flowBody
        }
    }

    private var flowBody: some View {
        // The full-screen paged counter flow. Unlike the iPhone's half-and-half split,
        // the watch shows no form-guide figure beneath it — the small screen is given
        // over entirely to the up/down timer.
        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, 2)
            }
        }
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button {
                    showingCancelConfirm = true
                } label: {
                    Image(systemName: "xmark")
                }
            }
            ToolbarItem(placement: .topBarTrailing) {
                heartRateReadout
            }
        }
        .confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
            Button("Cancel Exercise", role: .destructive) { dismiss() }
            Button("Continue", role: .cancel) { }
        }
        .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 phone 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 mirror 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)
            }
        }
        .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
            // the phone). 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. 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 first unfinished set. 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 and broadcast (via finishRestore) so a mirroring phone follows
                // across the exercise boundary.
                beginExercise()
                finishRestore()
            } else {
                // Not-started opens on the Ready page; the screenshot host pins its own.
                finishRestore()
            }
        }
        .onDisappear {
            // Leaving the flow (cancel / done / back) — stop the phone mirror.
            guard debugInitialPage == nil else { return }
            onLiveEnded()
        }
    }

    // MARK: - Live heart rate vs. target

    /// Compact live HR against this run's target (cardio routines only): bpm tinted by
    /// band with a cue arrow — ease off (down) or push harder (up). Renders nothing
    /// unless the run carries a target and a sample exists; dims under Always-On like
    /// the timers.
    @ViewBuilder
    private var heartRateReadout: some View {
        if let target = doc.targetHeartRate, let bpm = sessionManager?.currentHeartRate {
            let band = HeartRateBand(bpm: bpm, target: target)
            HStack(spacing: 2) {
                Text("\(Int(bpm.rounded()))")
                    .monospacedDigit()
                Image(systemName: band.cueSymbol)
            }
            .font(.caption2.weight(.semibold))
            .foregroundStyle(dimmed ? Color.secondary : band.tint)
        }
    }

    // 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 mirror.
    private func finishRestore() {
        didRestorePage = true
        if let frame = incomingFrame, frame.logID == logID {
            applyIncoming(frame)
        } else {
            broadcastLive(for: currentPage)
        }
    }

    /// Push the current flow position to a mirroring iPhone. 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 phone's mirror timer lines up with ours.
    private func broadcastLive(for page: Int) {
        guard debugInitialPage == nil, 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 phone made: jump to the frame's page (matching its set count
    /// for a remote One More) without re-broadcasting or re-recording it — the phone 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 application context and is absorbed into `doc` by the parent list. 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 mirror 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)))
        }
    }

    /// 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))
    }

    /// 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 }
    }

    @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.
                    CountdownPhaseView(
                        header: "Rest",
                        tint: .restTimer,
                        seconds: effectiveRest,
                        isActive: isActive,
                        anchorStart: anchorStart,
                        anchorEnd: anchorEnd
                    ) { _ 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()
                        }
                    }
                } else {
                    // Finish page — confirm Done (auto-fires) or add One More.
                    FinishPhaseView(
                        isActive: isActive,
                        onDone: { completeExercise(); dismiss() },
                        onOneMore: addSet,
                        anchorEnd: anchorEnd,
                        autoFireAllowed: { autoCompletionPlausible }
                    )
                }
            } 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
                    ) { 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
                ) { end in
                    withAnimation { advance(from: index, phaseEndedAt: end) }
                }
            }
        }
    }

    // MARK: - Mutations

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

    /// 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 mirror 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 (and forwards it to the phone), 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; adjustments happen on the phone — no watch adjust UI in v1).
        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]
        // Skip the write if it's already pristine (e.g. landing on Ready before any set).
        knownStateIndex = 0
        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() {
        let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
        let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
        let anyInProgress = statuses.contains { $0 == .inProgress }
        let allNotStarted = statuses.allSatisfy { $0 == .notStarted }

        if allCompleted {
            doc.status = WorkoutStatus.completed.rawValue
            doc.end = Date()
        } else if anyInProgress || !allNotStarted {
            doc.status = WorkoutStatus.inProgress.rawValue
            doc.end = nil
        } else {
            doc.status = WorkoutStatus.notStarted.rawValue
            doc.end = nil
        }
    }

    // MARK: - Formatting

    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: - Haptics

/// The haptic vocabulary used across the flow (set start, countdown ping, rest end, done).
private enum WorkoutHaptic {
    case start, tick, stop, success

    @MainActor
    func play() {
        switch self {
        case .start:   WKInterfaceDevice.current().play(.start)
        case .tick:    WKInterfaceDevice.current().play(.notification)
        case .stop:    WKInterfaceDevice.current().play(.stop)
        case .success: WKInterfaceDevice.current().play(.success)
        }
    }
}

// MARK: - Phase Colors

/// watchOS is always dark, so these are static (matching the iPhone's dark-mode tints):
/// brand purple for work, light gray for rest.
private extension Color {
    static let workTimer = Color(red: 0.66, green: 0.45, blue: 0.96)
    static let restTimer = Color(white: 0.74)
}

// 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 Timer Layout

/// The live timer a phase shows, described by its wall-clock anchors so `PhaseTimerLayout` can
/// render it live (system timer text, ticking each second) while active, but fall back to a
/// held "~m:ss" snapshot in the Always-On / wrist-down state — where the system would otherwise
/// throttle the timer text and collapse sub-minute values to a truncated "< 1 min".
private enum PhaseTimer {
    /// Count up from a wall-clock anchor (work-set stopwatch).
    case countUp(from: Date)
    /// Count down across a wall-clock window (rest / timed work set).
    case countDown(from: Date, to: Date)
}

/// 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
/// footer reserves its height even when empty, keeping the timer centered the same way on
/// both pages.
private struct PhaseTimerLayout: View {
    let header: String
    let footer: String
    let tint: Color
    let timer: PhaseTimer
    /// True in the Always-On / wrist-down state (dimmed screen).
    @Environment(\.isLuminanceReduced) private var isLuminanceReduced

    var body: some View {
        VStack(spacing: 4) {
            Text(header)
                .font(.caption)
                .foregroundStyle(.secondary)

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

            Text(footer.isEmpty ? " " : footer)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    /// Live system timer text while active; a held "~m:ss" snapshot in Always-On, so the count
    /// stays numeric and on-screen instead of collapsing to the system's truncated "< 1 min".
    @ViewBuilder private var timerText: some View {
        if isLuminanceReduced {
            Text("~\(heldValue)")
        } else {
            switch timer {
            case .countUp(let start):
                Text(start, style: .timer)
            case .countDown(let start, let end):
                Text(timerInterval: start...end, countsDown: true)
            }
        }
    }

    /// The most-recent count as of this render, formatted to match the live timer.
    private var heldValue: String {
        switch timer {
        case .countUp(let start):
            return Self.clock(Date().timeIntervalSince(start))
        case .countDown(_, let end):
            return Self.clock(end.timeIntervalSinceNow)
        }
    }

    /// Formats a (clamped-non-negative) interval as `m:ss` / `h:mm:ss`, matching the system
    /// timer text's shape.
    private static func clock(_ interval: TimeInterval) -> String {
        let total = max(0, Int(interval.rounded()))
        let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60)
        return h > 0 ? String(format: "%d:%02d:%02d", h, m, s)
                     : String(format: "%d:%02d", m, s)
    }
}

// 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 = 5
    private let dashWidth: CGFloat = 13
    private let markerHeight: CGFloat = 5
    private let gap: CGFloat = 5
    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 full-width body.
    func phaseButtonLabel() -> some View {
        self
            .font(.system(.headline, design: .rounded, weight: .heavy))
            .frame(maxWidth: .infinity)
            .padding(.vertical, 4)
    }
}

// MARK: - Completed Phase

/// Shown instead of the run flow when the exercise was already completed on open.
private struct CompletedPhaseView: View {
    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 56))
                .foregroundStyle(.green)
            Text("Completed")
                .font(.system(.title3, design: .rounded, weight: .heavy))
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Skipped Phase

/// Shown instead of the run flow when the exercise was skipped (the workout was
/// ended early) — in gray, matching the list row's skipped icon.
private struct SkippedPhaseView: View {
    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: "forward.circle.fill")
                .font(.system(size: 56))
                .foregroundStyle(.gray)
            Text("Skipped")
                .font(.system(.title3, design: .rounded, weight: .heavy))
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// MARK: - Ready Phase

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

    var body: some View {
        VStack(spacing: 8) {
            Text("Ready?")
                .font(.system(size: 30, weight: .bold, design: .rounded))

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

            Button(action: onStart) {
                Text("Start")
                    .phaseButtonLabel()
            }
            .buttonStyle(.borderedProminent)
            .tint(.workTimer)
            .buttonBorderShape(.capsule)
            .padding(.top, 4)
        }
        .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. Driving the display from a fixed
    /// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
    /// on a run-loop `Timer` keeps it advancing in the Always-On / wrist-down state,
    /// where that timer is throttled and stops firing.
    @State private var startDate = Date()

    var body: some View {
        PhaseTimerLayout(header: "\(setNumber) of \(totalSets)", footer: detail, tint: .workTimer,
                         timer: .countUp(from: startDate))
        .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) } }
    }

    private func restart(haptic: Bool) {
        startDate = anchorStart ?? Date()
        if haptic { WorkoutHaptic.start.play() }
    }
}

// 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").
///
/// The display is driven by a wall-clock window (so it keeps counting down in the
/// Always-On / wrist-down state), and the haptics + auto-advance are derived from
/// `endDate` rather than a decremented counter — so they stay correct even after the
/// run-loop `Timer` was throttled while the wrist was down.
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
    /// 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

    @State private var startDate = Date()
    @State private var endDate = Date()
    /// 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
    private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()

    var body: some View {
        PhaseTimerLayout(header: header, footer: footer, tint: tint,
                         timer: .countDown(from: startDate, to: endDate))
        .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)))
        lastPingSecond = Int.max
        didFinish = false
        // No buzz 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() }
    }

    private func tick() {
        guard isActive, !didFinish else { return }
        // Round up so the final whole second still pings before we reach zero.
        let remaining = Int(ceil(endDate.timeIntervalSinceNow))

        if remaining <= 0 {
            // Time's up — final cue and slide on. If the wrist was down the timer may have
            // stalled; this then fires on the first tick once the app gets runtime again.
            didFinish = true
            // Buzz only for a boundary that just happened — fast-forwarding through
            // boundaries that passed while the device slept stays silent.
            if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
            onFinished(endDate)
        } else if remaining <= 3 && remaining < lastPingSecond {
            // Once-per-second countdown ping for the final three seconds.
            lastPingSecond = remaining
            WorkoutHaptic.tick.play()
        }
    }
}

// MARK: - Finish Phase

/// Terminal page after the last set. **Done** completes the exercise and dismisses — 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 (same Always-On rationale as the rest timer).
    /// `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: 8) {
            Button(action: fire) {
                Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
                    .phaseButtonLabel()
            }
            .buttonStyle(.borderedProminent)
            .tint(Color.workTimer)
            .buttonBorderShape(.capsule)

            Button(action: onOneMore) {
                Label("One More", systemImage: "plus")
                    .phaseButtonLabel()
            }
            .buttonStyle(.bordered)
            .buttonBorderShape(.capsule)
        }
        .padding(.horizontal)
        .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))
        remaining = max(0, r)
        if r <= 0, autoFireAllowed() { fire() }
    }

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