//
// WorkoutLogListView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 6:58 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//

import IndieSync
import SwiftUI
import SwiftData

struct WorkoutLogListView: View {
    @Environment(SyncEngine.self) private var sync
    @Environment(AppServices.self) private var services
    @Environment(LiveRunState.self) private var liveRun
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss

    let workout: Workout

    /// Working copy of the workout aggregate. Active-session edits mutate this
    /// local value (not the cache entity), which avoids losing rapid taps to the
    /// file→observer→cache lag, and is the single source of truth while the
    /// screen is open.
    @State private var doc: WorkoutDocument

    @State private var showingAddSheet = false
    @State private var showingEndOptions = false
    @State private var logToDelete: WorkoutLogDocument?
    @State private var addedLog: LogRoute?
    @State private var logToEdit: LogRoute?
    @State private var summaryRoute: LogRoute?
    @State private var settingsRoute: LogRoute?
    @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb

    /// Drives a programmatic push keyed by a log id, used for the freshly-added
    /// exercise (→ progress flow) and the Edit swipe (→ detail/edit screen). Rows
    /// navigate with plain destination-based `NavigationLink`s; these item-based
    /// `navigationDestination`s are each bound to their own state, so they don't
    /// double-fire the way a value-based `navigationDestination(for:)` would.
    private struct LogRoute: Identifiable, Hashable { let id: String }

    init(workout: Workout) {
        self.workout = workout
        // A closure-based `NavigationLink` builds this destination eagerly for every
        // row in the parent list — including during the view-graph update that fires
        // the instant a workout is deleted — so map only a live entity here; a deleted
        // one traps in SwiftData. The placeholder is never shown (the row is gone).
        _doc = State(initialValue: WorkoutDocument(fromLive: workout) ?? .deletedPlaceholder)
    }

    private var sortedLogs: [WorkoutLogDocument] {
        doc.logs.sorted { $0.order < $1.order }
    }

    /// The routine this workout was started from (for adding more exercises),
    /// following the seed clone-on-edit redirect so a mid-workout fork still
    /// resolves to the live clone.
    private var routine: Routine? {
        guard let routineID = doc.routineID else { return nil }
        return CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext)
    }

    var body: some View {
        Group {
            if sortedLogs.isEmpty {
                ContentUnavailableView {
                    Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
                } description: {
                    Text("Add exercises to start your workout.")
                } actions: {
                    Button {
                        showingAddSheet = true
                    } label: {
                        Text("Add Exercise")
                    }
                    .buttonStyle(.borderedProminent)
                }
            } else {
                Form {
                    Section {
                        LabeledContent("Started", value: doc.start.formattedRelativeDateTime())
                        LabeledContent("Status") {
                            // Emphasize the states that mean the workout amounted to
                            // something — a running or finished one — over notStarted.
                            let emphasized = statusEnum == .inProgress || statusEnum == .completed
                            Text(statusEnum.displayName)
                                .fontWeight(emphasized ? .bold : nil)
                                .foregroundStyle(emphasized ? Color.accentColor : Color.secondary)
                        }
                    }

                    Section(header: Text("Exercises")) {
                        ForEach(sortedLogs) { log in
                            NavigationLink {
                                progressView(logID: log.id)
                            } label: {
                                WorkoutLogRow(
                                    status: workoutStatus(log).checkboxStatus,
                                    log: log,
                                    weightUnit: weightUnit,
                                    onCheckboxTap: { cycleStatus(for: log) },
                                    onSettingsTap: { settingsRoute = LogRoute(id: log.id) }
                                )
                            }
                            .swipeActions(edge: .leading, allowsFullSwipe: true) {
                                Button {
                                    completeLog(log)
                                } label: {
                                    Label("Complete", systemImage: "checkmark.circle.fill")
                                }
                                .tint(.green)
                            }
                            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                                Button {
                                    logToDelete = log
                                } label: {
                                    Label("Delete", systemImage: "trash")
                                }
                                .tint(.red)

                                Button {
                                    logToEdit = LogRoute(id: log.id)
                                } label: {
                                    Label("Edit", systemImage: "pencil")
                                }
                                .tint(.blue)
                            }
                        }
                        .onMove { source, destination in
                            moveLog(from: source, to: destination)
                        }
                    }

                    // The trailing slot: a running workout ends here; a finished one
                    // shows its summary in the button's place.
                    if doc.status == WorkoutStatus.completed.rawValue {
                        if doc.metrics != nil {
                            Section("Summary") {
                                WorkoutMetricsView(workout: workout)
                            }
                        }
                    } else {
                        Section {
                            Button {
                                showingEndOptions = true
                            } label: {
                                Text("End Workout")
                                    .frame(maxWidth: .infinity)
                            }
                        }
                    }
                }
            }
        }
        .navigationDestination(item: $addedLog) { route in
            // A freshly-added exercise drops straight into its progress flow. The new
            // log already lives in our working doc, so the binding has it before the
            // cache catches up.
            progressView(logID: route.id)
        }
        .navigationDestination(item: $logToEdit) { route in
            // The Edit swipe opens the detail/edit screen, seeded with our working
            // doc so it shows the latest local state immediately.
            ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
        }
        .navigationTitle(doc.routineName ?? Routine.unnamed)
        // Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
        // cache reflects them, so the list shows live status on return. The observed
        // expression itself must be liveness-guarded too: it's evaluated on every
        // body pass, and reading a persisted property on a dead @Model traps.
        // Strictly-newer guard, matching the watch list: an equal stamp is our own
        // write echoing back, and a stale cache echo (late-delivered older file
        // version) must not regress the working copy mid-run — the next local save
        // would re-stamp that stale content as newest and clobber real progress.
        .onChange(of: workout.isLive ? workout.updatedAt : nil) { _, incoming in
            if let incoming, incoming > doc.updatedAt,
               let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    showingAddSheet = true
                } label: {
                    Image(systemName: "plus")
                }
                .accessibilityLabel("Add Exercise")
            }
        }
        .sheet(isPresented: $showingAddSheet) {
            RoutineExercisePickerSheet(
                routine: routine,
                existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
            ) { selection in
                switch selection {
                case .fromRoutine(let exercise): addExerciseFromRoutine(exercise)
                case .fromLibrary(let name): addExerciseFromLibrary(name)
                }
            }
        }
        .confirmationDialog(
            "Delete Exercise?",
            isPresented: Binding(
                get: { logToDelete != nil },
                set: { if !$0 { logToDelete = nil } }
            ),
            titleVisibility: .visible,
            presenting: logToDelete
        ) { log in
            Button("Delete", role: .destructive) {
                deleteLog(log)
                logToDelete = nil
            }
            Button("Cancel", role: .cancel) {
                logToDelete = nil
            }
        }
        .confirmationDialog(
            "End Workout?",
            isPresented: $showingEndOptions,
            titleVisibility: .visible
        ) {
            Button("Save Workout") { endWorkout() }
            Button("Discard Workout", role: .destructive) { discardWorkout() }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text(endProgressMessage)
        }
        .sheet(item: $summaryRoute) { route in
            WorkoutSummaryView(workoutID: route.id) {
                summaryRoute = nil
                dismiss()
            }
        }
        .sheet(item: $settingsRoute) { route in
            if let log = doc.logs.first(where: { $0.id == route.id }) {
                MachineSettingsSheet(
                    exerciseName: log.exerciseName,
                    initialSettings: log.machineSettings ?? []
                ) { updated in
                    applyMachineSettings(updated, toLogID: route.id)
                }
            }
        }
    }

    /// The paged run flow, fully wired into the live channel: it broadcasts this device's
    /// human transitions to the watch, follows the watch's, and marks this run as open inline
    /// (so the propped-phone mirror cover doesn't stack on top of it).
    @ViewBuilder
    private func progressView(logID: String) -> some View {
        // RunFlowView hosts the run and, in a flow-mode routine, auto-advances across
        // exercises. It owns the live-run mirror wiring (incoming frame filter,
        // `navigatedRunID`, and the parameterized live-ended signal) since the on-screen
        // log advances during a flowing run.
        RunFlowView(
            doc: $doc,
            startLogID: logID,
            onChange: { save() },
            onLive: { services.watchBridge.sendLiveProgress($0) },
            sendLiveEnded: { lid in services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: lid) },
            onActivity: { services.liveActivity.update($0) },
            onActivityEnded: { services.liveActivity.end() },
            speechAnnouncer: services.speechAnnouncer
        )
    }

    // MARK: - Derived

    private var statusEnum: WorkoutStatus {
        WorkoutStatus(rawValue: doc.status) ?? .notStarted
    }

    private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus {
        WorkoutStatus(rawValue: log.status) ?? .notStarted
    }

    /// Progress summary shown when ending a workout early.
    private var endProgressMessage: String {
        let done = doc.logs.filter { workoutStatus($0) == .completed }.count
        return "\(done) of \(doc.logs.count) exercises completed."
    }

    // MARK: - Mutations (drive the local doc, persist via SyncEngine)

    private func cycleStatus(for log: WorkoutLogDocument) {
        guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
        let next: WorkoutStatus
        switch workoutStatus(log) {
        case .notStarted: next = .inProgress
        case .inProgress: next = .completed
        case .completed: next = .notStarted
        case .skipped: next = .notStarted
        }
        doc.logs[i].transition(to: next)
        save()
    }

    private func completeLog(_ log: WorkoutLogDocument) {
        guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
        doc.logs[i].transition(to: .completed)
        save()
    }

    private func deleteLog(_ log: WorkoutLogDocument) {
        withAnimation {
            doc.logs.removeAll { $0.id == log.id }
            // Tombstone the deletion so a stale watch push still carrying this log can't
            // resurrect it — the per-log merge drops a tombstoned id (see WorkoutMergePlanner).
            doc.deletedLogIDs = (doc.deletedLogIDs ?? [:]).merging([log.id: Date()]) { _, new in new }
            save()
        }
    }

    private func moveLog(from source: IndexSet, to destination: Int) {
        var logs = sortedLogs
        logs.move(fromOffsets: source, toOffset: destination)
        for (index, log) in logs.enumerated() {
            if let i = doc.logs.firstIndex(where: { $0.id == log.id }), doc.logs[i].order != index {
                doc.logs[i].order = index
                doc.logs[i].touch()
            }
        }
        save()
    }

    private func addExerciseFromRoutine(_ exercise: Exercise) {
        appendExercise(ExerciseDocument(from: exercise))
    }

    /// Add an exercise that isn't part of this workout's routine, picked from the full
    /// exercise library. Seeds a plan from, in priority order: the most recent logged
    /// set of this exercise (any workout), the library's authored `**Defaults:**`, or
    /// a plain 3×10 fallback when neither exists.
    private func addExerciseFromLibrary(_ name: String) {
        appendExercise(libraryPlan(for: name))
    }

    /// Append a freshly-planned exercise to the working doc, persist, and push
    /// straight into its progress flow. Shared tail of `addExerciseFromRoutine` and
    /// `addExerciseFromLibrary`.
    private func appendExercise(_ plan: ExerciseDocument) {
        let now = Date()

        // Reuse the workout's start time only when it's the very first exercise.
        if doc.logs.isEmpty {
            doc.start = now
        }
        doc.end = nil

        var newLog = WorkoutLogDocument(planFrom: plan, order: doc.logs.count, date: now)
        newLog.touch(now)   // a fresh log carries a definite creation time for the merge
        doc.logs.append(newLog)
        save()

        // Push the new exercise straight away.
        addedLog = LogRoute(id: newLog.id)
    }

    /// Plan defaults for a library exercise with no routine entry: copy the most recent
    /// logged set of this exercise (sets/reps/weight/loadType/duration and machine
    /// settings) if one exists anywhere; otherwise the library's authored defaults
    /// (weight always starts at 0 — there's no prior lift to copy); otherwise a plain
    /// 3×10 bodyweight-style fallback.
    private func libraryPlan(for name: String) -> ExerciseDocument {
        if let recent = mostRecentLog(named: name) {
            return ExerciseDocument(
                id: ULID.make(), name: name, order: doc.logs.count,
                sets: recent.sets, reps: recent.reps, weight: recent.weight,
                loadType: recent.loadType, durationSeconds: recent.durationTotalSeconds,
                machineSettings: recent.machineSettings, libraryName: recent.libraryName
            )
        }
        let info = ExerciseInfoLibrary.info(for: name)
        if let defaults = info?.defaults {
            return ExerciseDocument(
                id: ULID.make(), name: name, order: doc.logs.count,
                sets: defaults.sets, reps: defaults.reps, weight: 0,
                loadType: defaults.loadType.rawValue, durationSeconds: defaults.durationSeconds,
                machineSettings: info?.isMachineBased == true ? [] : nil
            )
        }
        return ExerciseDocument(
            id: ULID.make(), name: name, order: doc.logs.count,
            sets: 3, reps: 10, weight: 0, loadType: LoadType.none.rawValue, durationSeconds: 0
        )
    }

    /// Most recent `WorkoutLog` cache entity logged under `name`, across all
    /// workouts — the best available signal for "what did I actually do last time."
    private func mostRecentLog(named name: String) -> WorkoutLog? {
        var descriptor = FetchDescriptor<WorkoutLog>(
            predicate: #Predicate { $0.exerciseName == name },
            sortBy: [SortDescriptor(\.date, order: .reverse)]
        )
        descriptor.fetchLimit = 1
        return try? modelContext.fetch(descriptor).first
    }

    /// Apply edited machine settings from the sheet: update the log's plan-time
    /// snapshot (persisted with the workout), then write the same settings back to
    /// the originating routine's exercise as the durable default. Sequenced in one
    /// task so the routine's clone-on-edit repoint (which rewrites this workout's
    /// `routineID`) sees the log edit already persisted rather than clobbering it.
    private func applyMachineSettings(_ settings: [MachineSetting], toLogID id: String) {
        guard let i = doc.logs.firstIndex(where: { $0.id == id }) else { return }
        doc.logs[i].machineSettings = settings
        doc.logs[i].touch()
        doc.recomputeStatusFromLogs()
        doc.updatedAt = Date()

        let workoutSnapshot = doc
        let exerciseName = doc.logs[i].exerciseName
        let routineID = doc.routineID
        Task {
            await sync.save(workout: workoutSnapshot)
            await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
        }
    }

    /// Recompute the workout's status/end from its logs, then persist.
    private func save() {
        doc.recomputeStatusFromLogs()
        doc.updatedAt = Date()
        let snapshot = doc
        Task { await sync.save(workout: snapshot) }
    }

    /// Finish a partially-done workout: mark every not-completed exercise as skipped, so
    /// the recompute resolves the workout to `.completed` (stamping `end`). The status flip
    /// is what drops the run off the watch's active list and ends its HealthKit session.
    private func endWorkout() {
        doc.endKeepingProgress()
        let snapshot = doc
        Task { await sync.save(workout: snapshot) }
        // Show the summary (metrics fill in live as they arrive) instead of leaving
        // immediately; dismissing the summary returns to the log list.
        summaryRoute = LogRoute(id: snapshot.id)
    }

    /// Throw the workout away entirely (soft-delete via the tombstone path).
    private func discardWorkout() {
        let target = workout
        dismiss()
        Task { await sync.delete(workout: target) }
    }

}

// MARK: - Workout Log Row

/// One exercise line item: the status checkbox, the exercise name with the
/// machine settings line under it, and a trailing column with the load on top
/// and sets × reps below. Weight only appears for weighted exercises — a
/// bodyweight exercise (`LoadType.none`) shows no load at all.
private struct WorkoutLogRow: View {
    let status: CheckboxStatus
    let log: WorkoutLogDocument
    let weightUnit: WeightUnit
    let onCheckboxTap: () -> Void
    let onSettingsTap: () -> Void

    private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none }

    /// Spoken status for the checkbox: the same states the icon encodes, plus the
    /// set progress while an exercise is mid-flight (which the icon can't show).
    private var statusAccessibilityValue: String {
        switch WorkoutStatus(rawValue: log.status) ?? .notStarted {
        case .notStarted: return "Not started"
        case .inProgress:
            let done = min(max(0, log.currentStateIndex), log.sets)
            return "In progress, \(done) of \(log.sets) sets"
        case .completed: return "Completed"
        case .skipped: return "Skipped"
        }
    }

    /// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
    /// strength and the × dimmed, so the counts read at a glance. A single set
    /// drops the redundant "1 ×" and shows just the amount — "45 sec" or
    /// "10 reps" — with reps carrying an explicit unit so the bare number reads
    /// unambiguously (durations already include their "sec"/"min").
    private var setsAndReps: Text {
        let count: String
        if loadType == .duration {
            let mins = log.durationSeconds / 60
            let secs = log.durationSeconds % 60
            if mins > 0 && secs > 0 {
                count = "\(mins)m \(secs)s"
            } else if mins > 0 {
                count = "\(mins) min"
            } else {
                count = "\(secs) sec"
            }
        } else {
            count = "\(log.reps)"
        }
        if log.sets == 1 {
            if loadType == .duration { return Text(count) }
            return Text("\(count) \(log.reps == 1 ? "rep" : "reps")")
        }
        return Text("\(log.sets)\(Text(" × ").foregroundStyle(.secondary))\(count)")
    }

    private var showsWeight: Bool { loadType == .weight }

    /// Compact glanceable summary of the recorded machine settings ("Seat 4 · Back 3")
    /// so the values are readable while setting up the machine, without opening the
    /// sheet. Nil when nothing is recorded yet — the button then reads "Settings".
    private var settingsSummary: String? {
        guard let settings = log.machineSettings, !settings.isEmpty else { return nil }
        let parts = settings
            .map { "\($0.name) \($0.value)".trimmingCharacters(in: .whitespaces) }
            .filter { !$0.isEmpty }
        return parts.isEmpty ? nil : parts.joined(separator: " · ")
    }

    /// Top-align when either column stacks two lines (weight over sets × reps,
    /// or name over settings), so the first lines pair up. A single-line row —
    /// a bodyweight exercise with no settings — centers against the checkbox
    /// instead of hanging off its top edge.
    private var rowAlignment: VerticalAlignment {
        showsWeight || settingsSummary != nil ? .top : .center
    }

    var body: some View {
        HStack(alignment: rowAlignment) {
            Button {
                onCheckboxTap()
            } label: {
                Image(systemName: status.systemName)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 30, height: 30)
                    .foregroundStyle(status.color)
            }
            .buttonStyle(.plain)
            .accessibilityLabel(log.exerciseName)
            .accessibilityValue(statusAccessibilityValue)
            .accessibilityHint("Advances this exercise's completion status")

            VStack(alignment: .leading, spacing: 4) {
                Text(log.exerciseName)
                    .font(.headline)
                    .foregroundColor(.primary)

                // Machine comfort settings — the line appears only once settings are
                // actually recorded (an unconfigured machine shows nothing; recording
                // the first values happens in the exercise's edit screen or progress
                // flow). Tapping opens the editor sheet; `.plain` keeps the tap from
                // firing the row's NavigationLink (same trick as the checkbox).
                if let settingsSummary {
                    Button(action: onSettingsTap) {
                        Label(settingsSummary, systemImage: "slider.horizontal.3")
                            .font(.subheadline.weight(.medium))
                            .foregroundStyle(.secondary)
                            .lineLimit(2)
                    }
                    .buttonStyle(.plain)
                }
            }

            Spacer()

            // The weight matches the exercise name's font so the two sit on the
            // same visual line; sets × reps keeps its larger glanceable size below.
            VStack(alignment: .trailing, spacing: 4) {
                if showsWeight {
                    Text(weightUnit.format(log.weight))
                        .font(.headline)
                        .monospacedDigit()
                        .foregroundStyle(.primary)
                }

                setsAndReps
                    .font(.title3)
                    .monospacedDigit()
                    .foregroundStyle(.primary)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

// MARK: - Routine Exercise Picker Sheet

struct RoutineExercisePickerSheet: View {
    @Environment(\.dismiss) private var dismiss

    let routine: Routine?
    let existingExerciseNames: Set<String>
    let onSelected: (Selection) -> Void
    @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
    @State private var searchText = ""
    /// Parsed once per sheet presentation (`onAppear`), not per row render — the
    /// library section's name list is stable for the sheet's lifetime.
    @State private var libraryInfoByName: [String: ExerciseInfo] = [:]

    /// A pick from either source: the routine path hands back the live `Exercise`
    /// entity (as today), the library path just the exercise name.
    enum Selection {
        case fromRoutine(Exercise)
        case fromLibrary(String)
    }

    /// Section 1 — this workout's routine, minus exercises already added.
    private var routineExercises: [Exercise] {
        guard let routine else { return [] }
        return routine.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
    }

    /// Section 2 — every library exercise not already in the workout and not
    /// already offered in section 1.
    private var libraryExerciseNames: [String] {
        let shown = Set(routineExercises.map(\.name))
        return ExerciseMotionLibrary.exerciseNames.filter {
            !existingExerciseNames.contains($0) && !shown.contains($0)
        }
    }

    private var filteredRoutineExercises: [Exercise] {
        guard !searchText.isEmpty else { return routineExercises }
        return routineExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
    }

    private var filteredLibraryNames: [String] {
        guard !searchText.isEmpty else { return libraryExerciseNames }
        return libraryExerciseNames.filter { $0.localizedCaseInsensitiveContains(searchText) }
    }

    /// Whether there's anything left to add at all — independent of the current
    /// search, so a no-results search doesn't get confused with a truly empty sheet.
    private var hasAnyExercises: Bool {
        !routineExercises.isEmpty || !libraryExerciseNames.isEmpty
    }

    /// "3 × 12" / "3 × 30 s" — the library's suggested plan for a row's caption.
    private func defaultsCaption(_ defaults: ExerciseInfo.Defaults) -> String {
        switch defaults.loadType {
        case .duration: "\(defaults.sets) × \(defaults.durationSeconds) s"
        case .weight, .none: "\(defaults.sets) × \(defaults.reps)"
        }
    }

    var body: some View {
        NavigationStack {
            Group {
                if !hasAnyExercises {
                    ContentUnavailableView(
                        "All Exercises Added",
                        systemImage: "checkmark.circle",
                        description: Text("Every exercise is already part of this workout.")
                    )
                } else if filteredRoutineExercises.isEmpty && filteredLibraryNames.isEmpty {
                    ContentUnavailableView.search(text: searchText)
                } else {
                    List {
                        if !filteredRoutineExercises.isEmpty, let routine {
                            Section("From \(routine.name)") {
                                ForEach(filteredRoutineExercises) { exercise in
                                    Button {
                                        onSelected(.fromRoutine(exercise))
                                        dismiss()
                                    } label: {
                                        HStack {
                                            VStack(alignment: .leading) {
                                                Text(exercise.name)
                                                    .foregroundColor(.primary)
                                                Text(exercise.planSummary(weightUnit: weightUnit))
                                                    .font(.caption)
                                                    .foregroundColor(.secondary)
                                            }
                                            Spacer()
                                            Image(systemName: "plus.circle")
                                                .foregroundColor(.accentColor)
                                        }
                                    }
                                }
                            }
                        }

                        if !filteredLibraryNames.isEmpty {
                            Section("All Exercises") {
                                ForEach(filteredLibraryNames, id: \.self) { name in
                                    Button {
                                        onSelected(.fromLibrary(name))
                                        dismiss()
                                    } label: {
                                        HStack {
                                            VStack(alignment: .leading) {
                                                Text(name)
                                                    .foregroundColor(.primary)
                                                if let defaults = libraryInfoByName[name]?.defaults {
                                                    Text(defaultsCaption(defaults))
                                                        .font(.caption)
                                                        .foregroundColor(.secondary)
                                                }
                                            }
                                            Spacer()
                                            Image(systemName: "plus.circle")
                                                .foregroundColor(.accentColor)
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            .searchable(text: $searchText)
            .onAppear {
                guard libraryInfoByName.isEmpty else { return }
                for name in libraryExerciseNames {
                    libraryInfoByName[name] = ExerciseInfoLibrary.info(for: name)
                }
            }
            .navigationTitle("Add Exercise")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
            }
        }
    }
}

// MARK: - Machine Settings Sheet

/// In-workout editor for a log's machine comfort settings. Only presented for a
/// machine-based log (non-nil `machineSettings`), so there's no machine toggle —
/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save
/// hands the edited array back to the caller, which persists it to the log and
/// writes it back to the routine's exercise as the new default. Shared with
/// `ExerciseView`'s Machine Settings section.
struct MachineSettingsSheet: View {
    @Environment(\.dismiss) private var dismiss

    let exerciseName: String
    @State private var settings: [MachineSetting]
    let onSave: ([MachineSetting]) -> Void

    init(exerciseName: String, initialSettings: [MachineSetting], onSave: @escaping ([MachineSetting]) -> Void) {
        self.exerciseName = exerciseName
        self._settings = State(initialValue: initialSettings)
        self.onSave = onSave
    }

    var body: some View {
        NavigationStack {
            Form {
                Section(
                    header: Text("Settings"),
                    footer: Text("Saved to this workout and set as the default for \(exerciseName) in its routine.")
                ) {
                    MachineSettingsEditor(settings: $settings)
                }
            }
            .navigationTitle(exerciseName)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        onSave(settings)
                        dismiss()
                    }
                }
            }
        }
    }
}
