import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'package:watch_bridge_contract/watch_bridge_contract.dart';

import '../domain/domain.dart';
import 'ports.dart';
import 'starter_content/basket_starter_seed_v1.dart';
import 'starter_content/starter_content.dart';
import 'watch_companion_use_cases.dart';

const Object _useCaseUnchanged = Object();

List<T> filterByRequiredTags<T>(
  List<T> items,
  Set<String> requiredTags,
  List<String> Function(T item) tagsOf,
) {
  final normalizedRequiredTags = normalizeTags(requiredTags).toSet();
  if (normalizedRequiredTags.isEmpty) {
    return items;
  }
  return items
      .where(
        (item) => normalizeTags(
          tagsOf(item),
        ).toSet().containsAll(normalizedRequiredTags),
      )
      .toList();
}

List<TagUsage> tagSuggestionsFor<T>(
  List<T> items,
  List<String> Function(T item) tagsOf,
) {
  final counts = <String, int>{};
  for (final item in items) {
    for (final tag in normalizeTags(tagsOf(item))) {
      counts[tag] = (counts[tag] ?? 0) + 1;
    }
  }
  final suggestions = [
    for (final entry in counts.entries)
      TagUsage(tag: entry.key, count: entry.value),
  ];
  suggestions.sort((left, right) {
    final countComparison = right.count.compareTo(left.count);
    if (countComparison != 0) {
      return countComparison;
    }
    return left.tag.compareTo(right.tag);
  });
  return suggestions;
}

final class LocalBackupCodec {
  const LocalBackupCodec();

  static const kind = 'gametime.localBackup';
  static const formatVersion = 1;
  static const minSupportedFormatVersion = 1;

  Uint8List encode(LocalDataExportSnapshot snapshot) {
    final document = <String, Object?>{
      'kind': kind,
      'formatVersion': formatVersion,
      'minSupportedFormatVersion': minSupportedFormatVersion,
      'exportedAt': snapshot.exportedAt.toUtc().toIso8601String(),
      'appSchemaVersion': snapshot.appSchemaVersion,
      'originDeviceId': snapshot.originDeviceId,
      'counts': _countsToJson(snapshot.counts),
      'data': {
        'mediaAssets': snapshot.mediaAssets
            .map((resource) => resource.payload)
            .toList(),
        'exercises': snapshot.exercises
            .map((resource) => resource.payload)
            .toList(),
        'programs': snapshot.programs
            .map((resource) => resource.payload)
            .toList(),
        'workoutTemplates': snapshot.workoutTemplates
            .map((resource) => resource.payload)
            .toList(),
        'workoutHistories': snapshot.workoutHistories
            .map((resource) => resource.payload)
            .toList(),
      },
      'mediaFiles': snapshot.mediaFiles.map(_mediaFileToJson).toList(),
    };
    return Uint8List.fromList(utf8.encode(jsonEncode(document)));
  }

  LocalDataExportSnapshot decode(Uint8List bytes) {
    final Object? decoded;
    try {
      decoded = jsonDecode(utf8.decode(bytes));
    } on FormatException {
      throw const LocalBackupException(LocalBackupValidationError.corrupted);
    } on Object {
      throw const LocalBackupException(LocalBackupValidationError.invalidFile);
    }
    if (decoded is! Map) {
      throw const LocalBackupException(LocalBackupValidationError.invalidFile);
    }
    final root = Map<String, Object?>.from(decoded);
    if (root['kind'] != kind) {
      throw const LocalBackupException(LocalBackupValidationError.invalidFile);
    }
    final version = root['formatVersion'];
    final minSupported = root['minSupportedFormatVersion'];
    if (version is! int || minSupported is! int || version < 1) {
      throw const LocalBackupException(
        LocalBackupValidationError.incompatibleFormat,
      );
    }
    if (version > formatVersion || minSupported > formatVersion) {
      throw const LocalBackupException(LocalBackupValidationError.newerVersion);
    }
    final data = root['data'];
    if (data is! Map) {
      throw const LocalBackupException(LocalBackupValidationError.corrupted);
    }
    final dataMap = Map<String, Object?>.from(data);
    return LocalDataExportSnapshot(
      exportedAt: _dateTimeFromBackup(root['exportedAt']),
      appSchemaVersion: root['appSchemaVersion'] as int? ?? 1,
      originDeviceId: root['originDeviceId'] as String? ?? 'local-device',
      mediaAssets: _resourcesFromBackup(dataMap, 'mediaAssets'),
      exercises: _resourcesFromBackup(dataMap, 'exercises'),
      programs: _resourcesFromBackup(dataMap, 'programs'),
      workoutTemplates: _resourcesFromBackup(dataMap, 'workoutTemplates'),
      workoutHistories: _resourcesFromBackup(dataMap, 'workoutHistories'),
      mediaFiles: _mediaFilesFromBackup(root['mediaFiles']),
    );
  }

  LocalBackupPreview preview(Uint8List bytes) {
    final snapshot = decode(bytes);
    return LocalBackupPreview(
      exportedAt: snapshot.exportedAt,
      counts: snapshot.counts,
      hasEmbeddedMedia: snapshot.mediaFiles.isNotEmpty,
      missingMediaCount: _missingMediaCount(snapshot),
      hasNameDuplicates: _hasNameDuplicates(snapshot),
    );
  }
}

final class DataExportUseCase {
  const DataExportUseCase({
    required this.repository,
    required this.mediaStore,
    required this.clock,
    this.codec = const LocalBackupCodec(),
  });

  final LocalDataBackupRepository repository;
  final LocalBackupMediaStore mediaStore;
  final Clock clock;
  final LocalBackupCodec codec;

  Future<LocalBackupDocument> exportAll() async {
    final exportedAt = clock.now();
    final snapshot = await repository.readExportSnapshot(exportedAt);
    final mediaFiles = await mediaStore.readEmbeddableFiles(
      snapshot.mediaAssets,
    );
    final completeSnapshot = snapshot.copyWith(mediaFiles: mediaFiles);
    return LocalBackupDocument(
      fileName: _localBackupFileName(exportedAt),
      bytes: codec.encode(completeSnapshot),
    );
  }
}

final class DataImportUseCase {
  const DataImportUseCase({
    required this.repository,
    required this.mediaStore,
    required this.clock,
    this.codec = const LocalBackupCodec(),
  });

  final LocalDataBackupRepository repository;
  final LocalBackupMediaStore mediaStore;
  final Clock clock;
  final LocalBackupCodec codec;

  Future<LocalBackupPreview> preview(Uint8List bytes) async {
    return codec.preview(bytes);
  }

  Future<bool> hasAnyLocalData() => repository.hasAnyUserData();

  Future<LocalBackupImportResult> importFrom(
    Uint8List bytes, {
    required LocalBackupImportMode mode,
  }) async {
    if (await repository.hasOpenActiveWorkoutSession()) {
      throw const LocalBackupException(
        LocalBackupValidationError.activeWorkoutInProgress,
      );
    }
    final decoded = codec.decode(bytes);
    final restoredMediaFiles = await mediaStore.restoreEmbeddedFiles(
      decoded.mediaFiles,
    );
    final snapshot = _withRestoredMediaUris(decoded, restoredMediaFiles);
    try {
      final result = await repository.applyImportSnapshot(
        snapshot: snapshot,
        mode: mode,
        importedAt: clock.now(),
      );
      return result.copyWith(
        missingMediaCount: _missingRestoredMediaCount(
          decoded,
          restoredMediaFiles,
        ),
      );
    } on LocalBackupException {
      rethrow;
    } on Object {
      throw const LocalBackupException(
        LocalBackupValidationError.importFailedNoMutation,
      );
    }
  }
}

enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }

final class StarterSeedResult {
  const StarterSeedResult({required this.status, required this.appliedVersion});

  final StarterSeedStatus status;
  final int appliedVersion;
}

final class SeedStarterContentUseCase {
  const SeedStarterContentUseCase({
    required this.seedStateRepository,
    required this.contentRepository,
    required this.clock,
    required this.originDeviceId,
  });

  final StarterSeedStateRepository seedStateRepository;
  final StarterContentRepository contentRepository;
  final Clock clock;
  final String originDeviceId;

  Future<StarterSeedResult> run() async {
    final appliedVersion = await seedStateRepository
        .readAppliedStarterSeedVersion();
    if (appliedVersion >= starterSeedVersion) {
      return const StarterSeedResult(
        status: StarterSeedStatus.skippedAlreadyApplied,
        appliedVersion: starterSeedVersion,
      );
    }

    final now = clock.now();
    final isEmpty = await contentRepository.isLocalContentEmpty();
    if (!isEmpty) {
      await seedStateRepository.writeAppliedStarterSeedVersion(
        starterSeedVersion,
        now,
      );
      return const StarterSeedResult(
        status: StarterSeedStatus.skippedNotEmpty,
        appliedVersion: starterSeedVersion,
      );
    }

    await contentRepository.insertStarterContent(
      buildStarterContent(
        exerciseSeeds: basketStarterExerciseSeedsV1,
        programSeed: basketStarterProgramSeedV1,
        templateSeed: basketStarterWorkoutTemplateSeedV1,
        now: now,
        originDeviceId: originDeviceId,
      ),
    );
    await seedStateRepository.writeAppliedStarterSeedVersion(
      starterSeedVersion,
      now,
    );
    return const StarterSeedResult(
      status: StarterSeedStatus.inserted,
      appliedVersion: starterSeedVersion,
    );
  }
}

final class AuthUseCases {
  const AuthUseCases({
    required this.tokenStore,
    required this.accountRepository,
    required this.remoteAuthApi,
    required this.clock,
    required this.ids,
  });

  final AuthTokenStore tokenStore;
  final OnlineAccountRepository accountRepository;
  final RemoteAuthApi remoteAuthApi;
  final Clock clock;
  final IdGenerator ids;

  Future<UserAccountSession> register({
    required String email,
    required String password,
    String? displayName,
  }) async {
    _validateAuthInput(email: email, password: password);
    final result = await remoteAuthApi.register(
      email: email.trim(),
      password: password,
      displayName: displayName,
    );
    return _persistAuthenticatedSession(result, displayName: displayName);
  }

  Future<UserAccountSession> login({
    required String email,
    required String password,
  }) async {
    _validateAuthInput(email: email, password: password);
    final result = await remoteAuthApi.login(
      email: email.trim(),
      password: password,
    );
    return _persistAuthenticatedSession(result);
  }

  Future<void> logout() async {
    final token = await tokenStore.readToken();
    if (token != null) {
      try {
        await remoteAuthApi.logout(token);
      } on RemoteAuthException {
        // Local logout must not depend on remote reachability.
      }
    }
    await tokenStore.clearToken();
    final session = await accountRepository.currentSession();
    if (session != null) {
      final now = clock.now();
      await accountRepository.saveSession(
        session.copyWith(isLoggedIn: false, updatedAt: now),
      );
    }
  }

  Future<UserAccountSession?> currentSession() async {
    final session = await accountRepository.currentSession();
    if (session == null) {
      return null;
    }
    final token = await tokenStore.readToken();
    if (token != null || !session.isLoggedIn) {
      return session;
    }
    final updated = session.copyWith(isLoggedIn: false, updatedAt: clock.now());
    await accountRepository.saveSession(updated);
    return updated;
  }

  Future<UserAccountSession> _persistAuthenticatedSession(
    RemoteAuthResult result, {
    String? displayName,
  }) async {
    await tokenStore.saveToken(result.token, result.expiresAt);
    final now = clock.now();
    final existing = await accountRepository.currentSession();
    final session = UserAccountSession(
      id: existing?.id ?? ids.newId(),
      serverUserId:
          existing != null &&
              existing.email.trim().toLowerCase() ==
                  result.email.trim().toLowerCase()
          ? existing.serverUserId
          : result.userId,
      email: result.email,
      displayName: displayName ?? existing?.displayName,
      isLoggedIn: true,
      createdAt: existing?.createdAt ?? now,
      updatedAt: now,
      lastAuthenticatedAt: now,
    );
    await accountRepository.saveSession(session);
    return session;
  }
}

final class SyncUseCases {
  const SyncUseCases({
    required this.tokenStore,
    required this.remoteSyncApi,
    required this.metadataRepository,
    required this.mappingRepository,
    required this.localChanges,
    required this.clock,
    required this.deviceId,
  });

  final AuthTokenStore tokenStore;
  final RemoteSyncApi remoteSyncApi;
  final SyncMetadataRepository metadataRepository;
  final RemoteResourceMappingRepository mappingRepository;
  final LocalSyncChangeRepository localChanges;
  final Clock clock;
  final String deviceId;

  Future<SyncMetadataSnapshot> currentStatus() {
    return metadataRepository.read();
  }

  Future<SyncRunSummary> synchronize({required bool manual}) async {
    final token = await tokenStore.readToken();
    if (token == null) {
      return const SyncRunSummary(
        pushedChanges: 0,
        pulledChanges: 0,
        skipped: true,
        message: 'Not connected.',
      );
    }

    final previousMetadata = await metadataRepository.read();
    final attemptAt = clock.now();
    await metadataRepository.save(
      previousMetadata.copyWith(
        lastAttemptAt: attemptAt,
        status: OnlineSyncStatus.syncing,
      ),
    );

    try {
      final pendingChanges = await localChanges.listPendingChanges();
      var pushedCount = 0;
      if (pendingChanges.isNotEmpty) {
        final pushResult = await remoteSyncApi.push(
          deviceId: deviceId,
          items: pendingChanges.map((change) => change.item).toList(),
          token: token,
        );
        final changesByKey = {
          for (final change in pendingChanges)
            _syncKey(change.item.resourceType, change.item.clientId): change,
        };
        final syncedChangeLogIds = <String>[];
        for (final result in pushResult.results) {
          final change =
              changesByKey[_syncKey(result.resourceType, result.clientId)];
          if (change == null || result.status == RemoteSyncPushStatus.error) {
            continue;
          }
          pushedCount += result.status == RemoteSyncPushStatus.accepted ? 1 : 0;
          syncedChangeLogIds.addAll(change.changeLogIds);
          if (result.serverId != null && result.serverUpdatedAt != null) {
            await mappingRepository.save(
              RemoteResourceMapping(
                resourceType: result.resourceType,
                clientId: result.clientId,
                serverId: result.serverId!,
                serverUpdatedAt: result.serverUpdatedAt!,
              ),
            );
          }
        }
        if (syncedChangeLogIds.isNotEmpty) {
          await localChanges.markChangesSynced(syncedChangeLogIds, clock.now());
        }
      }

      final metadataBeforePull = await metadataRepository.read();
      final pullResult = await remoteSyncApi.pull(
        since: metadataBeforePull.serverCursor,
        token: token,
      );
      var pulledCount = 0;
      for (final item in pullResult.items) {
        await mappingRepository.save(
          RemoteResourceMapping(
            resourceType: item.resourceType,
            clientId: item.clientId,
            serverId: item.serverId,
            serverUpdatedAt: item.serverUpdatedAt,
          ),
        );
        final applied = await localChanges.applyRemoteItem(item);
        if (applied) {
          pulledCount += 1;
        }
      }

      final completedAt = clock.now();
      await metadataRepository.save(
        (await metadataRepository.read()).copyWith(
          serverCursor: pullResult.serverCursor,
          lastSuccessfulSyncAt: completedAt,
          lastFailureAt: null,
          status: OnlineSyncStatus.success,
          pendingPushCount: 0,
        ),
      );
      return SyncRunSummary(
        pushedChanges: pushedCount,
        pulledChanges: pulledCount,
      );
    } catch (error) {
      await metadataRepository.save(
        (await metadataRepository.read()).copyWith(
          lastFailureAt: clock.now(),
          status: OnlineSyncStatus.failure,
        ),
      );
      return SyncRunSummary(
        pushedChanges: 0,
        pulledChanges: 0,
        failed: true,
        message: error.toString(),
      );
    }
  }
}

final class ShareUseCases {
  const ShareUseCases({
    required this.tokenStore,
    required this.remoteShareApi,
    required this.inboxRepository,
    required this.pendingActionRepository,
    required this.localChanges,
    required this.programRepository,
    required this.templateRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final AuthTokenStore tokenStore;
  final RemoteShareApi remoteShareApi;
  final ShareInboxRepository inboxRepository;
  final PendingShareActionRepository pendingActionRepository;
  final LocalSyncChangeRepository localChanges;
  final ProgramRepository programRepository;
  final WorkoutTemplateRepository templateRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<ShareSendResult> sendShare({
    required ShareResourceType resourceType,
    required String localResourceId,
    required List<String> recipientEmails,
    String? packName,
    List<String> localResourceIds = const [],
  }) async {
    final emails = recipientEmails
        .map((email) => email.trim())
        .where((email) => email.isNotEmpty)
        .toList(growable: false);
    if (emails.isEmpty) {
      throw const DomainException('At least one recipient email is required.');
    }
    final payload = await _sharePayload(
      resourceType,
      localResourceId,
      packName: packName,
      localResourceIds: localResourceIds,
    );
    final token = await tokenStore.readToken();
    if (token == null) {
      return const ShareSendResult(status: ShareSendStatus.notConnected);
    }
    try {
      final result = await remoteShareApi.sendShare(
        resourceType: resourceType,
        payload: payload,
        recipientEmails: emails,
        token: token,
      );
      return ShareSendResult(
        status: ShareSendStatus.sent,
        shareId: result.shareId,
        recipientUserIds: result.recipientUserIds,
        unresolvedEmails: result.unresolvedEmails,
      );
    } catch (_) {
      final action = _pendingShareAction(
        actionType: PendingShareActionType.send,
        resourceType: resourceType,
        payload: payload,
        recipientEmails: emails,
      );
      await pendingActionRepository.add(action);
      return ShareSendResult(
        status: ShareSendStatus.queued,
        pendingActionId: action.id,
      );
    }
  }

  Future<List<ShareInboxItem>> refreshInbox() async {
    final token = await tokenStore.readToken();
    if (token == null) {
      return inboxRepository.listAll();
    }
    try {
      final items = await remoteShareApi.fetchInbox(token);
      await inboxRepository.upsertAll(items);
      return inboxRepository.listAll();
    } catch (_) {
      return inboxRepository.listAll();
    }
  }

  Future<List<WorkoutTemplate>> listShareableWorkoutTemplates() {
    return templateRepository.listActive();
  }

  Future<void> acceptShare(String shareId) async {
    final cachedItem = await inboxRepository.findByShareId(shareId);
    final token = await tokenStore.readToken();
    if (token == null) {
      await _acceptCachedShareAndQueueAck(shareId, cachedItem);
      return;
    }
    try {
      final createdResources = await remoteShareApi.acceptShare(shareId, token);
      final cachedPackItem = cachedItem;
      if (cachedPackItem != null &&
          cachedPackItem.resourceType == ShareResourceType.pack) {
        await _importSharedPayload(cachedPackItem);
      } else {
        for (final createdResource in createdResources) {
          await localChanges.applyRemoteItem(createdResource);
        }
      }
      await inboxRepository.markStatus(
        shareId,
        ShareInboxStatus.accepted,
        clock.now(),
      );
    } catch (_) {
      await _acceptCachedShareAndQueueAck(shareId, cachedItem);
    }
  }

  Future<void> declineShare(String shareId) async {
    final token = await tokenStore.readToken();
    if (token == null) {
      return;
    }
    try {
      await remoteShareApi.declineShare(shareId, token);
      await inboxRepository.markStatus(
        shareId,
        ShareInboxStatus.declined,
        clock.now(),
      );
    } catch (_) {
      await pendingActionRepository.add(
        _pendingShareAction(
          actionType: PendingShareActionType.decline,
          shareId: shareId,
        ),
      );
    }
  }

  Future<void> revokeShare(String shareId) async {
    final token = await tokenStore.readToken();
    if (token == null) {
      return;
    }
    try {
      await remoteShareApi.revokeShare(shareId, token);
      await inboxRepository.markStatus(
        shareId,
        ShareInboxStatus.revoked,
        clock.now(),
      );
    } catch (_) {
      await pendingActionRepository.add(
        _pendingShareAction(
          actionType: PendingShareActionType.revoke,
          shareId: shareId,
        ),
      );
    }
  }

  Future<void> processPendingShareActions() async {
    final token = await tokenStore.readToken();
    if (token == null) {
      return;
    }
    final actions = await pendingActionRepository.listPending();
    for (final action in actions) {
      final attemptedAt = clock.now();
      try {
        await _processPendingShareAction(action, token);
        await pendingActionRepository.markSucceeded(action.id, attemptedAt);
      } catch (_) {
        await pendingActionRepository.markFailed(action.id, attemptedAt);
      }
    }
  }

  Future<Map<String, Object?>> _sharePayload(
    ShareResourceType resourceType,
    String localResourceId, {
    String? packName,
    List<String> localResourceIds = const [],
  }) async {
    switch (resourceType) {
      case ShareResourceType.program:
        final program = await programRepository.findById(localResourceId);
        if (program == null) {
          throw const DomainException('Program not found.');
        }
        return _programSharePayload(program);
      case ShareResourceType.workoutTemplate:
        final template = await templateRepository.findById(localResourceId);
        if (template == null) {
          throw const DomainException('Workout template not found.');
        }
        return _workoutTemplateSharePayload(template);
      case ShareResourceType.pack:
        final name = packName?.trim();
        if (name == null || name.isEmpty) {
          throw const DomainException('Pack name is required.');
        }
        final ids = localResourceIds
            .map((id) => id.trim())
            .where((id) => id.isNotEmpty)
            .toList(growable: false);
        if (ids.isEmpty) {
          throw const DomainException('At least one workout is required.');
        }
        final templates = <WorkoutTemplate>[];
        for (final id in ids) {
          final template = await templateRepository.findById(id);
          if (template == null) {
            throw const DomainException('Workout template not found.');
          }
          templates.add(template);
        }
        return _packSharePayload(name, templates);
    }
  }

  PendingShareAction _pendingShareAction({
    required PendingShareActionType actionType,
    String? shareId,
    ShareResourceType? resourceType,
    Map<String, Object?>? payload,
    List<String>? recipientEmails,
  }) {
    return PendingShareAction(
      id: ids.newId(),
      actionType: actionType,
      shareId: shareId,
      resourceType: resourceType,
      payloadJson: payload == null ? null : jsonEncode(payload),
      recipientEmailsJson: recipientEmails == null
          ? null
          : jsonEncode(recipientEmails),
      createdAt: clock.now(),
    );
  }

  Future<void> _processPendingShareAction(
    PendingShareAction action,
    String token,
  ) async {
    switch (action.actionType) {
      case PendingShareActionType.send:
        await remoteShareApi.sendShare(
          resourceType: action.resourceType!,
          payload: _jsonObject(action.payloadJson),
          recipientEmails: _jsonStringList(action.recipientEmailsJson),
          token: token,
        );
        return;
      case PendingShareActionType.accept:
        final item = await inboxRepository.findByShareId(action.shareId!);
        if (item?.resourceType == ShareResourceType.pack) {
          await remoteShareApi.acceptShare(action.shareId!, token);
        } else {
          final createdResources = await remoteShareApi.acceptShare(
            action.shareId!,
            token,
          );
          for (final createdResource in createdResources) {
            await localChanges.applyRemoteItem(createdResource);
          }
        }
        await inboxRepository.markStatus(
          action.shareId!,
          ShareInboxStatus.accepted,
          clock.now(),
        );
        return;
      case PendingShareActionType.decline:
        await remoteShareApi.declineShare(action.shareId!, token);
        await inboxRepository.markStatus(
          action.shareId!,
          ShareInboxStatus.declined,
          clock.now(),
        );
        return;
      case PendingShareActionType.revoke:
        await remoteShareApi.revokeShare(action.shareId!, token);
        await inboxRepository.markStatus(
          action.shareId!,
          ShareInboxStatus.revoked,
          clock.now(),
        );
        return;
    }
  }

  Future<void> _acceptCachedShareAndQueueAck(
    String shareId,
    ShareInboxItem? item,
  ) async {
    if (item != null && item.status == ShareInboxStatus.pending) {
      await _importSharedPayload(item);
      await inboxRepository.markStatus(
        shareId,
        ShareInboxStatus.accepted,
        clock.now(),
      );
    }
    await pendingActionRepository.add(
      _pendingShareAction(
        actionType: PendingShareActionType.accept,
        shareId: shareId,
      ),
    );
  }

  Future<void> _importSharedPayload(ShareInboxItem item) async {
    final payload = _jsonObject(item.payloadJson);
    final now = clock.now();
    switch (item.resourceType) {
      case ShareResourceType.program:
        await programRepository.save(
          _programCopyFromSharePayload(payload, now),
        );
        return;
      case ShareResourceType.workoutTemplate:
        await templateRepository.save(
          _workoutTemplateCopyFromSharePayload(payload, now),
        );
        return;
      case ShareResourceType.pack:
        final templates = _workoutTemplateCopiesFromPackSharePayload(
          payload,
          now,
        );
        await templateRepository.saveAll(templates);
        return;
    }
  }

  List<WorkoutTemplate> _workoutTemplateCopiesFromPackSharePayload(
    Map<String, Object?> payload,
    DateTime now,
  ) {
    final rawItems = payload['workouts'];
    if (rawItems is! List || rawItems.isEmpty) {
      throw const DomainException('Pack payload is empty.');
    }
    final templates = <WorkoutTemplate>[];
    for (final raw in rawItems) {
      if (raw is! Map) {
        throw const DomainException('Invalid pack payload.');
      }
      templates.add(
        _workoutTemplateCopyFromSharePayload(
          Map<String, Object?>.from(raw),
          now,
        ),
      );
    }
    return templates;
  }

  Program _programCopyFromSharePayload(
    Map<String, Object?> payload,
    DateTime now,
  ) {
    final programId = ids.newId();
    final programMetadata = EntityMetadata(
      id: programId,
      createdAt: now,
      updatedAt: now,
      originDeviceId: originDeviceId,
    );
    return Program(
      metadata: programMetadata,
      name: _stringFromSharePayload(payload, 'name', 'Programme partagé'),
      defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
      exercises: _programExerciseCopiesFromSharePayload(
        payload['exercises'],
        programId,
        now,
      ),
    );
  }

  List<ProgramExercise> _programExerciseCopiesFromSharePayload(
    Object? value,
    String programId,
    DateTime now,
  ) {
    if (value is! List) {
      return const [];
    }
    final exercises = <ProgramExercise>[];
    for (var index = 0; index < value.length; index++) {
      final raw = value[index];
      if (raw is! Map) {
        continue;
      }
      final map = Map<String, Object?>.from(raw);
      exercises.add(
        ProgramExercise(
          metadata: EntityMetadata(
            id: ids.newId(),
            createdAt: now,
            updatedAt: now,
            originDeviceId: originDeviceId,
          ),
          programId: programId,
          sourceExerciseId: null,
          position: map['position'] as int? ?? index,
          exerciseNameSnapshot: _stringFromSharePayload(
            map,
            'exerciseNameSnapshot',
            'Exercice partagé',
          ),
          exerciseDescriptionSnapshot:
              map['exerciseDescriptionSnapshot'] as String?,
          exerciseImageMediaIdSnapshot:
              map['exerciseImageMediaIdSnapshot'] as String?,
          exerciseImageMediaIdsSnapshot: _stringListFromSharePayload(
            map['exerciseImageMediaIdsSnapshot'] ??
                map['imageMediaIdsSnapshot'],
          ),
          exerciseVideoMediaIdSnapshot:
              map['exerciseVideoMediaIdSnapshot'] as String?,
          exerciseStepsSnapshot: _exerciseStepsFromSnapshot(
            map['exerciseStepsSnapshot'],
          ),
          autoStartNextTimedStepSnapshot:
              map['autoStartNextTimedStepSnapshot'] as bool? ?? true,
          autoStartNextTimedStepOverride:
              map['autoStartNextTimedStepOverride'] as bool?,
          exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true,
          availableTimeSnapshot: map['availableTimeSnapshot'] == true,
          availableRepsSnapshot: map['availableRepsSnapshot'] == true,
          availableScoreSnapshot: map['availableScoreSnapshot'] == true,
          scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
            map['scoreInputModeSnapshot'],
          ),
          scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
          scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
          setsCount: map['setsCount'] as int? ?? 1,
          timeEnabled: map['timeEnabled'] == true,
          repsEnabled: map['repsEnabled'] == true,
          scoreEnabled: map['scoreEnabled'] == true,
          targetTimeSeconds: map['targetTimeSeconds'] as int?,
          targetReps: map['targetReps'] as int?,
          targetScore: (map['targetScore'] as num?)?.toDouble(),
          targetScoreTimeMs: map['targetScoreTimeMs'] as int?,
          restSecondsOverride: map['restSecondsOverride'] as int?,
        ),
      );
    }
    return exercises;
  }

  WorkoutTemplate _workoutTemplateCopyFromSharePayload(
    Map<String, Object?> payload,
    DateTime now,
  ) {
    final templateId = ids.newId();
    final programIdBySharedId = <String, String>{};
    final programs = _workoutTemplateProgramCopiesFromSharePayload(
      payload['programs'],
      templateId,
      programIdBySharedId,
      now,
    );
    return WorkoutTemplate(
      metadata: EntityMetadata(
        id: templateId,
        createdAt: now,
        updatedAt: now,
        originDeviceId: originDeviceId,
      ),
      name: _stringFromSharePayload(payload, 'name', 'Séance partagée'),
      lastStartedAt: null,
      programs: programs,
      overrides: _workoutTemplateOverrideCopiesFromSharePayload(
        payload['overrides'],
        programIdBySharedId,
        now,
      ),
    );
  }

  List<WorkoutTemplateProgram> _workoutTemplateProgramCopiesFromSharePayload(
    Object? value,
    String templateId,
    Map<String, String> programIdBySharedId,
    DateTime now,
  ) {
    if (value is! List) {
      return const [];
    }
    final programs = <WorkoutTemplateProgram>[];
    for (var index = 0; index < value.length; index++) {
      final raw = value[index];
      if (raw is! Map) {
        continue;
      }
      final map = Map<String, Object?>.from(raw);
      final copiedId = ids.newId();
      final sharedId = map['id'] as String?;
      if (sharedId != null) {
        programIdBySharedId[sharedId] = copiedId;
      }
      programs.add(
        WorkoutTemplateProgram(
          metadata: EntityMetadata(
            id: copiedId,
            createdAt: now,
            updatedAt: now,
            originDeviceId: originDeviceId,
          ),
          workoutTemplateId: templateId,
          sourceProgramId: null,
          position: map['position'] as int? ?? index,
          programNameSnapshot: _stringFromSharePayload(
            map,
            'programNameSnapshot',
            'Programme partagé',
          ),
          defaultRestSecondsSnapshot:
              map['defaultRestSecondsSnapshot'] as int? ?? 0,
          programSnapshotJson:
              map['programSnapshotJson'] as String? ?? '{"exercises":[]}',
        ),
      );
    }
    return programs;
  }

  List<WorkoutTemplateExerciseOverride>
  _workoutTemplateOverrideCopiesFromSharePayload(
    Object? value,
    Map<String, String> programIdBySharedId,
    DateTime now,
  ) {
    if (value is! List) {
      return const [];
    }
    final overrides = <WorkoutTemplateExerciseOverride>[];
    for (final raw in value) {
      if (raw is! Map) {
        continue;
      }
      final map = Map<String, Object?>.from(raw);
      final sharedProgramId = map['workoutTemplateProgramId'] as String?;
      final copiedProgramId = programIdBySharedId[sharedProgramId];
      if (copiedProgramId == null) {
        continue;
      }
      final snapshotProgramExerciseId = map['snapshotProgramExerciseId'];
      if (snapshotProgramExerciseId is! String ||
          snapshotProgramExerciseId.trim().isEmpty) {
        continue;
      }
      overrides.add(
        WorkoutTemplateExerciseOverride(
          metadata: EntityMetadata(
            id: ids.newId(),
            createdAt: now,
            updatedAt: now,
            originDeviceId: originDeviceId,
          ),
          workoutTemplateProgramId: copiedProgramId,
          snapshotProgramExerciseId: snapshotProgramExerciseId,
          setsCountOverride: map['setsCountOverride'] as int?,
          targetTimeSecondsOverride: map['targetTimeSecondsOverride'] as int?,
          targetRepsOverride: map['targetRepsOverride'] as int?,
          targetScoreOverride: (map['targetScoreOverride'] as num?)?.toDouble(),
          targetScoreTimeMsOverride: map['targetScoreTimeMsOverride'] as int?,
          autoStartNextTimedStepOverride:
              map['autoStartNextTimedStepOverride'] as bool?,
        ),
      );
    }
    return overrides;
  }
}

final class ExerciseUseCases {
  const ExerciseUseCases({
    required this.repository,
    required this.programRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final ExerciseRepository repository;
  final ProgramRepository programRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<Exercise> create({
    required String name,
    String? description,
    String? imageMediaId,
    List<String> imageMediaIds = const [],
    String? iconMediaId,
    String? videoMediaId,
    required bool hasTimeMeasure,
    required bool hasRepsMeasure,
    required bool hasScoreMeasure,
    ScoreInputMode scoreInputMode = ScoreInputMode.manual,
    String? scoreLabel,
    String? scoreUnit,
    int? defaultTargetTimeSeconds,
    int? defaultTargetReps,
    double? defaultTargetScore,
    int? defaultTargetScoreTimeMs,
    bool autoStartNextTimedStep = true,
    List<BusinessExerciseType> businessTypes = const [],
    List<String> tags = const [],
    List<ExerciseStep> steps = const [],
  }) async {
    _validateExerciseSteps(steps);
    _validateExerciseDefaultTargets(
      hasTimeMeasure: hasTimeMeasure,
      hasRepsMeasure: hasRepsMeasure,
      hasScoreMeasure: hasScoreMeasure,
      scoreInputMode: scoreInputMode,
      defaultTargetTimeSeconds: defaultTargetTimeSeconds,
      defaultTargetReps: defaultTargetReps,
      defaultTargetScore: defaultTargetScore,
      defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
    );
    final now = clock.now();
    final exercise = Exercise(
      metadata: _newMetadata(ids, originDeviceId, now),
      name: name,
      description: description,
      imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds),
      iconMediaId: iconMediaId,
      videoMediaId: videoMediaId,
      hasTimeMeasure: hasTimeMeasure,
      hasRepsMeasure: hasRepsMeasure,
      hasScoreMeasure: hasScoreMeasure,
      scoreInputMode: scoreInputMode,
      scoreLabel: scoreLabel,
      scoreUnit: scoreUnit,
      defaultTargetTimeSeconds: defaultTargetTimeSeconds,
      defaultTargetReps: defaultTargetReps,
      defaultTargetScore: defaultTargetScore,
      defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
      autoStartNextTimedStep: autoStartNextTimedStep,
      businessTypes: businessTypes,
      tags: tags,
      steps: steps,
    );
    await repository.save(exercise);
    return exercise;
  }

  Future<void> save(Exercise exercise) => repository.save(exercise);

  Future<List<Exercise>> listActive() => repository.listActive();

  Future<bool> isUsedInPrograms(String id) {
    return repository.isReferencedByProgram(id);
  }

  Future<Exercise> update({
    required String id,
    required String name,
    String? description,
    String? imageMediaId,
    Object? imageMediaIds = _useCaseUnchanged,
    Object? iconMediaId = _useCaseUnchanged,
    String? videoMediaId,
    required bool hasTimeMeasure,
    required bool hasRepsMeasure,
    required bool hasScoreMeasure,
    ScoreInputMode scoreInputMode = ScoreInputMode.manual,
    String? scoreLabel,
    String? scoreUnit,
    Object? defaultTargetTimeSeconds = _useCaseUnchanged,
    Object? defaultTargetReps = _useCaseUnchanged,
    Object? defaultTargetScore = _useCaseUnchanged,
    Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
    bool? autoStartNextTimedStep,
    Object? businessTypes = _useCaseUnchanged,
    List<String> tags = const [],
    Object? steps = _useCaseUnchanged,
  }) async {
    final exercise = await repository.findById(id);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final resolvedDefaultTargetTimeSeconds =
        defaultTargetTimeSeconds == _useCaseUnchanged
        ? exercise.defaultTargetTimeSeconds
        : defaultTargetTimeSeconds as int?;
    final resolvedDefaultTargetReps = defaultTargetReps == _useCaseUnchanged
        ? exercise.defaultTargetReps
        : defaultTargetReps as int?;
    final resolvedDefaultTargetScore = defaultTargetScore == _useCaseUnchanged
        ? exercise.defaultTargetScore
        : (defaultTargetScore as num?)?.toDouble();
    final resolvedDefaultTargetScoreTimeMs =
        defaultTargetScoreTimeMs == _useCaseUnchanged
        ? exercise.defaultTargetScoreTimeMs
        : defaultTargetScoreTimeMs as int?;
    _validateExerciseDefaultTargets(
      hasTimeMeasure: hasTimeMeasure,
      hasRepsMeasure: hasRepsMeasure,
      hasScoreMeasure: hasScoreMeasure,
      scoreInputMode: scoreInputMode,
      defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds,
      defaultTargetReps: resolvedDefaultTargetReps,
      defaultTargetScore: resolvedDefaultTargetScore,
      defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
    );
    final resolvedSteps = steps == _useCaseUnchanged
        ? exercise.steps
        : steps as List<ExerciseStep>;
    _validateExerciseSteps(resolvedSteps);
    final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
        ? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
        : imageMediaIds as List<String>;
    final resolvedIconMediaId = iconMediaId == _useCaseUnchanged
        ? exercise.iconMediaId != null &&
                  resolvedImageMediaIds.contains(exercise.iconMediaId)
              ? exercise.iconMediaId
              : null
        : iconMediaId as String?;
    final updated = exercise.copyWith(
      metadata: exercise.metadata.touch(clock.now()),
      name: name,
      description: description,
      imageMediaIds: resolvedImageMediaIds,
      iconMediaId: resolvedIconMediaId,
      videoMediaId: videoMediaId,
      hasTimeMeasure: hasTimeMeasure,
      hasRepsMeasure: hasRepsMeasure,
      hasScoreMeasure: hasScoreMeasure,
      scoreInputMode: scoreInputMode,
      scoreLabel: scoreLabel,
      scoreUnit: scoreUnit,
      defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds,
      defaultTargetReps: resolvedDefaultTargetReps,
      defaultTargetScore: resolvedDefaultTargetScore,
      defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
      autoStartNextTimedStep: autoStartNextTimedStep,
      businessTypes: businessTypes == _useCaseUnchanged
          ? exercise.businessTypes
          : businessTypes as List<BusinessExerciseType>,
      tags: tags,
      steps: resolvedSteps,
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> attachImage({
    required String exerciseId,
    required String mediaAssetId,
  }) async {
    final exercise = await repository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final updated = exercise.copyWith(
      imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
      metadata: exercise.metadata.touch(clock.now()),
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> addImage({
    required String exerciseId,
    required String mediaAssetId,
  }) async {
    final exercise = await repository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final updated = exercise.copyWith(
      imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
      metadata: exercise.metadata.touch(clock.now()),
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> removeImage({
    required String exerciseId,
    required String mediaAssetId,
  }) async {
    final exercise = await repository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final updated = exercise.copyWith(
      imageMediaIds: exercise.imageMediaIds
          .where((imageId) => imageId != mediaAssetId)
          .toList(),
      iconMediaId: exercise.iconMediaId == mediaAssetId
          ? null
          : exercise.iconMediaId,
      metadata: exercise.metadata.touch(clock.now()),
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> reorderImages({
    required String exerciseId,
    required List<String> orderedMediaIds,
  }) async {
    final exercise = await repository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    if (!_sameImageSet(exercise.imageMediaIds, orderedMediaIds)) {
      throw const DomainException(
        'Reordered exercise images must match existing images.',
      );
    }
    final updated = exercise.copyWith(
      imageMediaIds: orderedMediaIds,
      metadata: exercise.metadata.touch(clock.now()),
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> setIcon({
    required String exerciseId,
    required String? mediaAssetId,
  }) async {
    final exercise = await repository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final updated = exercise.copyWith(
      iconMediaId: mediaAssetId,
      metadata: exercise.metadata.touch(clock.now()),
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> attachVideo({
    required String exerciseId,
    required String mediaAssetId,
  }) async {
    final exercise = await repository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final updated = exercise.copyWith(
      videoMediaId: mediaAssetId,
      metadata: exercise.metadata.touch(clock.now()),
    );
    await repository.save(updated);
    return updated;
  }

  Future<Exercise> archive(String id) async {
    final exercise = await repository.findById(id);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final archived = exercise.archive(clock.now());
    await repository.save(archived);
    return archived;
  }

  Future<Exercise> delete(String id) async {
    final exercise = await repository.findById(id);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final now = clock.now();
    final deleted = exercise.copyWith(
      metadata: exercise.metadata.markDeleted(now),
    );
    await repository.save(deleted);
    return deleted;
  }
}

final class MediaUseCases {
  const MediaUseCases({
    required this.mediaRepository,
    required this.exerciseRepository,
    required this.storage,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final MediaAssetRepository mediaRepository;
  final ExerciseRepository exerciseRepository;
  final LocalMediaStorage storage;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<MediaAsset> importMedia({
    required String sourcePath,
    required MediaKind kind,
  }) async {
    final now = clock.now();
    final id = ids.newId();
    final stored = await storage.importFile(
      sourcePath: sourcePath,
      kind: kind,
      stableFileName: id,
    );
    final asset = MediaAsset(
      metadata: EntityMetadata(
        id: id,
        createdAt: now,
        updatedAt: now,
        originDeviceId: originDeviceId,
      ),
      kind: kind,
      localUri: stored.localUri,
      mimeType: stored.mimeType,
      sizeBytes: stored.sizeBytes,
      width: stored.width,
      height: stored.height,
      durationMs: stored.durationMs,
    );
    await mediaRepository.save(asset);
    return asset;
  }

  Future<MediaAsset?> findById(String id) {
    return mediaRepository.findById(id);
  }

  Future<Exercise> attachToExercise({
    required String exerciseId,
    required String mediaAssetId,
  }) async {
    final exercise = await exerciseRepository.findById(exerciseId);
    if (exercise == null) {
      throw const DomainException('Exercise not found.');
    }
    final media = await mediaRepository.findById(mediaAssetId);
    if (media == null) {
      throw const DomainException('Media asset not found.');
    }
    final updated = switch (media.kind) {
      MediaKind.image => exercise.copyWith(
        imageMediaIds: _appendExerciseImage(
          exercise.imageMediaIds,
          media.metadata.id,
        ),
        metadata: exercise.metadata.touch(clock.now()),
      ),
      MediaKind.video => exercise.copyWith(
        videoMediaId: media.metadata.id,
        metadata: exercise.metadata.touch(clock.now()),
      ),
    };
    await exerciseRepository.save(updated);
    return updated;
  }

  Future<int> deleteOrphanedManagedFiles() async {
    final activeAssets = await mediaRepository.listActive();
    final referencedUris = activeAssets.map((asset) => asset.localUri).toSet();
    final managedUris = await storage.listManagedLocalUris();
    final orphanedUris = managedUris.difference(referencedUris);
    for (final uri in orphanedUris) {
      await storage.deleteByLocalUri(uri);
    }
    return orphanedUris.length;
  }
}

final class ProgramUseCases {
  const ProgramUseCases({
    required this.programRepository,
    required this.exerciseRepository,
    required this.templateRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final ProgramRepository programRepository;
  final ExerciseRepository exerciseRepository;
  final WorkoutTemplateRepository templateRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<Program?> findById(String id) => programRepository.findById(id);

  Future<List<Program>> listActive() => programRepository.listActive();

  Future<bool> isUsedInWorkoutTemplates(String id) async {
    final templates = await templateRepository.listActive();
    return templates.any(
      (template) =>
          template.programs.any((program) => program.sourceProgramId == id),
    );
  }

  Future<Program> create({
    required String name,
    required int defaultRestSeconds,
    List<String> tags = const [],
  }) async {
    final now = clock.now();
    final program = Program(
      metadata: _newMetadata(ids, originDeviceId, now),
      name: name,
      defaultRestSeconds: defaultRestSeconds,
      tags: tags,
    );
    await programRepository.save(program);
    return program;
  }

  Future<Program> saveConfigured({
    String? id,
    required String name,
    required int defaultRestSeconds,
    List<String> tags = const [],
    required List<ProgramExerciseConfig> exercises,
  }) async {
    if (exercises.isEmpty) {
      throw const DomainException(
        'Program must contain at least one exercise.',
      );
    }
    final now = clock.now();
    final existing = id == null ? null : await programRepository.findById(id);
    if (id != null && existing == null) {
      throw const DomainException('Program not found.');
    }
    final programId = id ?? ids.newId();
    final metadata =
        existing?.metadata.touch(now) ??
        EntityMetadata(
          id: programId,
          createdAt: now,
          updatedAt: now,
          originDeviceId: originDeviceId,
        );
    final programExercises = <ProgramExercise>[];
    for (var index = 0; index < exercises.length; index++) {
      final input = exercises[index];
      final exerciseMetadata =
          input.existingMetadata?.touch(now) ??
          _newMetadata(ids, originDeviceId, now);
      programExercises.add(
        ProgramExercise(
          metadata: exerciseMetadata,
          programId: programId,
          sourceExerciseId: input.sourceExerciseId,
          position: index,
          exerciseNameSnapshot: input.exerciseNameSnapshot,
          exerciseDescriptionSnapshot: input.exerciseDescriptionSnapshot,
          exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot,
          exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
          exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
          exerciseStepsSnapshot: input.exerciseStepsSnapshot,
          autoStartNextTimedStepSnapshot: input.autoStartNextTimedStepSnapshot,
          autoStartNextTimedStepOverride: input.autoStartNextTimedStepOverride,
          exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
          availableTimeSnapshot: input.availableTimeSnapshot,
          availableRepsSnapshot: input.availableRepsSnapshot,
          availableScoreSnapshot: input.availableScoreSnapshot,
          scoreInputModeSnapshot: input.scoreInputModeSnapshot,
          scoreLabelSnapshot: input.scoreLabelSnapshot,
          scoreUnitSnapshot: input.scoreUnitSnapshot,
          setsCount: input.setsCount,
          timeEnabled: input.enabledMeasures.contains(WorkoutMeasure.time),
          repsEnabled: input.enabledMeasures.contains(WorkoutMeasure.reps),
          scoreEnabled: input.enabledMeasures.contains(WorkoutMeasure.score),
          targetTimeSeconds: input.targetTimeSeconds,
          targetReps: input.targetReps,
          targetScore: input.targetScore,
          targetScoreTimeMs: input.targetScoreTimeMs,
          restSecondsOverride: input.restSecondsOverride,
        ),
      );
    }
    final program = Program(
      metadata: metadata,
      name: name,
      defaultRestSeconds: defaultRestSeconds,
      isExample: existing?.isExample ?? false,
      tags: tags,
      exercises: programExercises,
    );
    await programRepository.replaceExercises(program, now);
    return program;
  }

  Future<Program> duplicate(String id) async {
    final source = await programRepository.findById(id);
    if (source == null) {
      throw const DomainException('Program not found.');
    }
    final activePrograms = await programRepository.listActive();
    final now = clock.now();
    final copyId = ids.newId();
    final copyExercises = [
      for (final exercise in source.exercises)
        ProgramExercise(
          metadata: _newMetadata(ids, originDeviceId, now),
          programId: copyId,
          sourceExerciseId: exercise.sourceExerciseId,
          position: exercise.position,
          exerciseNameSnapshot: exercise.exerciseNameSnapshot,
          exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot,
          exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot,
          exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot,
          exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
          exerciseStepsSnapshot: exercise.exerciseStepsSnapshot,
          autoStartNextTimedStepSnapshot:
              exercise.autoStartNextTimedStepSnapshot,
          autoStartNextTimedStepOverride:
              exercise.autoStartNextTimedStepOverride,
          exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
          availableTimeSnapshot: exercise.availableTimeSnapshot,
          availableRepsSnapshot: exercise.availableRepsSnapshot,
          availableScoreSnapshot: exercise.availableScoreSnapshot,
          scoreInputModeSnapshot: exercise.scoreInputModeSnapshot,
          scoreLabelSnapshot: exercise.scoreLabelSnapshot,
          scoreUnitSnapshot: exercise.scoreUnitSnapshot,
          setsCount: exercise.setsCount,
          timeEnabled: exercise.timeEnabled,
          repsEnabled: exercise.repsEnabled,
          scoreEnabled: exercise.scoreEnabled,
          targetTimeSeconds: exercise.targetTimeSeconds,
          targetReps: exercise.targetReps,
          targetScore: exercise.targetScore,
          targetScoreTimeMs: exercise.targetScoreTimeMs,
          restSecondsOverride: exercise.restSecondsOverride,
        ),
    ];
    final copy = Program(
      metadata: _newMetadataWithId(copyId, originDeviceId, now),
      name: _copyName(
        source.name,
        activePrograms.map((program) => program.name),
      ),
      defaultRestSeconds: source.defaultRestSeconds,
      isExample: false,
      tags: source.tags,
      exercises: copyExercises,
    );
    await programRepository.replaceExercises(copy, now);
    return copy;
  }

  Future<ProgramExercise> addExercise({
    required String programId,
    required String exerciseId,
    required int position,
    required int setsCount,
    required Set<WorkoutMeasure> enabledMeasures,
    int? targetTimeSeconds,
    int? targetReps,
    double? targetScore,
    int? targetScoreTimeMs,
    int? restSecondsOverride,
  }) async {
    final source = await exerciseRepository.findById(exerciseId);
    if (source == null) {
      throw const DomainException('Exercise not found.');
    }
    final now = clock.now();
    final programExercise = ProgramExercise.snapshotFromExercise(
      metadata: _newMetadata(ids, originDeviceId, now),
      programId: programId,
      exercise: source,
      position: position,
      setsCount: setsCount,
      enabledMeasures: enabledMeasures,
      targetTimeSeconds: targetTimeSeconds,
      targetReps: targetReps,
      targetScore: targetScore,
      targetScoreTimeMs: targetScoreTimeMs,
      restSecondsOverride: restSecondsOverride,
    );
    await programRepository.saveExercise(programExercise);
    return programExercise;
  }

  Future<Program> delete(String id) async {
    final program = await programRepository.findById(id);
    if (program == null) {
      throw const DomainException('Program not found.');
    }
    final now = clock.now();
    final deleted = program.copyWith(
      metadata: program.metadata.markDeleted(now),
    );
    final templates = await templateRepository.listActive();
    for (final template in templates) {
      final keptPrograms = template.programs
          .where((templateProgram) => templateProgram.sourceProgramId != id)
          .toList();
      if (keptPrograms.length == template.programs.length) {
        continue;
      }
      final repositionedPrograms = _repositionWorkoutTemplatePrograms(
        keptPrograms,
        now,
      );
      final keptProgramIds = repositionedPrograms
          .map((templateProgram) => templateProgram.metadata.id)
          .toSet();
      await templateRepository.replaceComposition(
        WorkoutTemplate(
          metadata: template.metadata.touch(now),
          name: template.name,
          lastStartedAt: template.lastStartedAt,
          isExample: template.isExample,
          tags: template.tags,
          programs: repositionedPrograms,
          overrides: template.overrides
              .where(
                (override) =>
                    keptProgramIds.contains(override.workoutTemplateProgramId),
              )
              .toList(),
        ),
        now,
      );
    }
    await programRepository.save(deleted);
    return deleted;
  }
}

final class ProgramExerciseConfig {
  const ProgramExerciseConfig({
    this.existingMetadata,
    this.sourceExerciseId,
    required this.exerciseNameSnapshot,
    this.exerciseDescriptionSnapshot,
    this.exerciseImageMediaIdSnapshot,
    this.exerciseImageMediaIdsSnapshot = const [],
    this.exerciseVideoMediaIdSnapshot,
    this.exerciseStepsSnapshot = const [],
    this.autoStartNextTimedStepSnapshot = true,
    this.autoStartNextTimedStepOverride,
    this.exerciseArchivedSnapshot = false,
    required this.availableTimeSnapshot,
    required this.availableRepsSnapshot,
    required this.availableScoreSnapshot,
    this.scoreInputModeSnapshot = ScoreInputMode.manual,
    this.scoreLabelSnapshot,
    this.scoreUnitSnapshot,
    required this.setsCount,
    required this.enabledMeasures,
    this.targetTimeSeconds,
    this.targetReps,
    this.targetScore,
    this.targetScoreTimeMs,
    this.restSecondsOverride,
  });

  final EntityMetadata? existingMetadata;
  final String? sourceExerciseId;
  final String exerciseNameSnapshot;
  final String? exerciseDescriptionSnapshot;
  final String? exerciseImageMediaIdSnapshot;
  final List<String> exerciseImageMediaIdsSnapshot;
  final String? exerciseVideoMediaIdSnapshot;
  final List<ExerciseStep> exerciseStepsSnapshot;
  final bool autoStartNextTimedStepSnapshot;
  final bool? autoStartNextTimedStepOverride;
  final bool exerciseArchivedSnapshot;
  final bool availableTimeSnapshot;
  final bool availableRepsSnapshot;
  final bool availableScoreSnapshot;
  final ScoreInputMode scoreInputModeSnapshot;
  final String? scoreLabelSnapshot;
  final String? scoreUnitSnapshot;
  final int setsCount;
  final Set<WorkoutMeasure> enabledMeasures;
  final int? targetTimeSeconds;
  final int? targetReps;
  final double? targetScore;
  final int? targetScoreTimeMs;
  final int? restSecondsOverride;
}

final class WorkoutTemplateUseCases {
  const WorkoutTemplateUseCases({
    required this.templateRepository,
    required this.programRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final WorkoutTemplateRepository templateRepository;
  final ProgramRepository programRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<WorkoutTemplate?> findById(String id) {
    return templateRepository.findById(id);
  }

  Future<List<WorkoutTemplate>> listActive() {
    return templateRepository.listActive();
  }

  Future<WorkoutTemplate> create({
    required String name,
    List<String> tags = const [],
  }) async {
    final now = clock.now();
    final template = WorkoutTemplate(
      metadata: _newMetadata(ids, originDeviceId, now),
      name: name,
      tags: tags,
    );
    await templateRepository.save(template);
    return template;
  }

  Future<WorkoutTemplate> saveConfigured({
    String? id,
    required String name,
    List<String> tags = const [],
    required List<WorkoutTemplateProgramConfig> programs,
    required List<WorkoutTemplateExerciseOverrideConfig> overrides,
  }) async {
    if (programs.isEmpty) {
      throw const DomainException(
        'Workout template must contain at least one program.',
      );
    }
    final now = clock.now();
    final existing = id == null ? null : await templateRepository.findById(id);
    if (id != null && existing == null) {
      throw const DomainException('Workout template not found.');
    }
    final templateId = id ?? ids.newId();
    final metadata =
        existing?.metadata.touch(now) ??
        EntityMetadata(
          id: templateId,
          createdAt: now,
          updatedAt: now,
          originDeviceId: originDeviceId,
        );
    final templatePrograms = <WorkoutTemplateProgram>[];
    final programIdsByClientKey = <String, String>{};
    for (var index = 0; index < programs.length; index++) {
      final input = programs[index];
      final programMetadata =
          input.existingMetadata?.touch(now) ??
          _newMetadata(ids, originDeviceId, now);
      programIdsByClientKey[input.clientKey] = programMetadata.id;
      templatePrograms.add(
        WorkoutTemplateProgram(
          metadata: programMetadata,
          workoutTemplateId: templateId,
          sourceProgramId: input.sourceProgramId,
          position: index,
          programNameSnapshot: input.programNameSnapshot,
          defaultRestSecondsSnapshot: input.defaultRestSecondsSnapshot,
          programSnapshotJson: input.programSnapshotJson,
        ),
      );
    }
    final templateOverrides = <WorkoutTemplateExerciseOverride>[];
    for (final input in overrides) {
      final programInput = programs.firstWhere(
        (program) => program.clientKey == input.workoutTemplateProgramClientKey,
        orElse: () =>
            throw const DomainException('Workout template program not found.'),
      );
      final workoutTemplateProgramId =
          programIdsByClientKey[input.workoutTemplateProgramClientKey];
      if (workoutTemplateProgramId == null) {
        throw const DomainException('Workout template program not found.');
      }
      _validateOverrideTargets(programInput.programSnapshotJson, input);
      templateOverrides.add(
        WorkoutTemplateExerciseOverride(
          metadata:
              input.existingMetadata?.touch(now) ??
              _newMetadata(ids, originDeviceId, now),
          workoutTemplateProgramId: workoutTemplateProgramId,
          snapshotProgramExerciseId: input.snapshotProgramExerciseId,
          setsCountOverride: input.setsCountOverride,
          targetTimeSecondsOverride: input.targetTimeSecondsOverride,
          targetRepsOverride: input.targetRepsOverride,
          targetScoreOverride: input.targetScoreOverride,
          targetScoreTimeMsOverride: input.targetScoreTimeMsOverride,
          autoStartNextTimedStepOverride: input.autoStartNextTimedStepOverride,
        ),
      );
    }
    final template = WorkoutTemplate(
      metadata: metadata,
      name: name,
      lastStartedAt: existing?.lastStartedAt,
      isExample: existing?.isExample ?? false,
      tags: tags,
      programs: templatePrograms,
      overrides: templateOverrides,
    );
    _ensurePlayableResolvedTemplateSnapshot(
      _resolvedTemplateSnapshotJson(template),
    );
    await templateRepository.replaceComposition(template, now);
    return template;
  }

  Future<WorkoutTemplate> duplicate(String id) async {
    final source = await templateRepository.findById(id);
    if (source == null) {
      throw const DomainException('Workout template not found.');
    }
    final activeTemplates = await templateRepository.listActive();
    final now = clock.now();
    final copyId = ids.newId();
    final templateProgramIdsBySourceId = <String, String>{};
    final copyPrograms = [
      for (final program in source.programs)
        () {
          final copyProgramId = ids.newId();
          templateProgramIdsBySourceId[program.metadata.id] = copyProgramId;
          return WorkoutTemplateProgram(
            metadata: _newMetadataWithId(copyProgramId, originDeviceId, now),
            workoutTemplateId: copyId,
            sourceProgramId: program.sourceProgramId,
            position: program.position,
            programNameSnapshot: program.programNameSnapshot,
            defaultRestSecondsSnapshot: program.defaultRestSecondsSnapshot,
            programSnapshotJson: program.programSnapshotJson,
          );
        }(),
    ];
    final copyOverrides = [
      for (final override in source.overrides)
        if (templateProgramIdsBySourceId[override.workoutTemplateProgramId]
            case final copyProgramId?)
          WorkoutTemplateExerciseOverride(
            metadata: _newMetadata(ids, originDeviceId, now),
            workoutTemplateProgramId: copyProgramId,
            snapshotProgramExerciseId: override.snapshotProgramExerciseId,
            setsCountOverride: override.setsCountOverride,
            targetTimeSecondsOverride: override.targetTimeSecondsOverride,
            targetRepsOverride: override.targetRepsOverride,
            targetScoreOverride: override.targetScoreOverride,
            targetScoreTimeMsOverride: override.targetScoreTimeMsOverride,
            autoStartNextTimedStepOverride:
                override.autoStartNextTimedStepOverride,
          ),
    ];
    final copy = WorkoutTemplate(
      metadata: _newMetadataWithId(copyId, originDeviceId, now),
      name: _copyName(
        source.name,
        activeTemplates.map((template) => template.name),
      ),
      lastStartedAt: null,
      isExample: false,
      tags: source.tags,
      programs: copyPrograms,
      overrides: copyOverrides,
    );
    await templateRepository.replaceComposition(copy, now);
    return copy;
  }

  Future<WorkoutTemplateProgram> addProgramSnapshot({
    required String workoutTemplateId,
    required String programId,
    required int position,
  }) async {
    final program = await programRepository.findById(programId);
    if (program == null) {
      throw const DomainException('Program not found.');
    }
    final now = clock.now();
    final snapshot = WorkoutTemplateProgram.snapshotFromProgram(
      metadata: _newMetadata(ids, originDeviceId, now),
      workoutTemplateId: workoutTemplateId,
      program: program,
      position: position,
    );
    await templateRepository.saveProgram(snapshot);
    return snapshot;
  }

  Future<WorkoutTemplateExerciseOverride> overrideExerciseTargets({
    required String workoutTemplateProgramId,
    required String snapshotProgramExerciseId,
    int? setsCountOverride,
    int? targetTimeSecondsOverride,
    int? targetRepsOverride,
    double? targetScoreOverride,
    int? targetScoreTimeMsOverride,
    bool? autoStartNextTimedStepOverride,
    Set<WorkoutMeasure>? attemptedMeasureOverride,
  }) async {
    if (attemptedMeasureOverride != null) {
      throw const DomainException(
        'Workout templates cannot override active measures.',
      );
    }
    final now = clock.now();
    final override = WorkoutTemplateExerciseOverride(
      metadata: _newMetadata(ids, originDeviceId, now),
      workoutTemplateProgramId: workoutTemplateProgramId,
      snapshotProgramExerciseId: snapshotProgramExerciseId,
      setsCountOverride: setsCountOverride,
      targetTimeSecondsOverride: targetTimeSecondsOverride,
      targetRepsOverride: targetRepsOverride,
      targetScoreOverride: targetScoreOverride,
      targetScoreTimeMsOverride: targetScoreTimeMsOverride,
      autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
    );
    await templateRepository.saveOverride(override);
    return override;
  }

  Future<WorkoutTemplate> delete(String id) async {
    final template = await templateRepository.findById(id);
    if (template == null) {
      throw const DomainException('Workout template not found.');
    }
    final now = clock.now();
    final deleted = WorkoutTemplate(
      metadata: template.metadata.markDeleted(now),
      name: template.name,
      lastStartedAt: template.lastStartedAt,
      tags: template.tags,
    );
    await templateRepository.save(deleted);
    return deleted;
  }
}

final class WorkoutTemplateProgramConfig {
  const WorkoutTemplateProgramConfig({
    required this.clientKey,
    this.existingMetadata,
    this.sourceProgramId,
    required this.programNameSnapshot,
    required this.defaultRestSecondsSnapshot,
    required this.programSnapshotJson,
  });

  final String clientKey;
  final EntityMetadata? existingMetadata;
  final String? sourceProgramId;
  final String programNameSnapshot;
  final int defaultRestSecondsSnapshot;
  final String programSnapshotJson;
}

final class WorkoutTemplateExerciseOverrideConfig {
  const WorkoutTemplateExerciseOverrideConfig({
    this.existingMetadata,
    required this.workoutTemplateProgramClientKey,
    required this.snapshotProgramExerciseId,
    this.setsCountOverride,
    this.targetTimeSecondsOverride,
    this.targetRepsOverride,
    this.targetScoreOverride,
    this.targetScoreTimeMsOverride,
    this.autoStartNextTimedStepOverride,
  });

  final EntityMetadata? existingMetadata;
  final String workoutTemplateProgramClientKey;
  final String snapshotProgramExerciseId;
  final int? setsCountOverride;
  final int? targetTimeSecondsOverride;
  final int? targetRepsOverride;
  final double? targetScoreOverride;
  final int? targetScoreTimeMsOverride;
  final bool? autoStartNextTimedStepOverride;
}

final class SetExecutionTimerStartResult {
  const SetExecutionTimerStartResult({
    this.setTimer,
    this.scoreStopwatch,
    this.stepProgress,
  });

  final ActiveSetTimerState? setTimer;
  final ActiveScoreStopwatchState? scoreStopwatch;
  final ActiveExerciseStepProgressState? stepProgress;
}

final class ManualScoreUpdateResult {
  const ManualScoreUpdateResult({required this.state, required this.changed});

  final ActiveManualScoreState state;
  final bool changed;
}

final class ActiveWorkoutSessionUseCases {
  const ActiveWorkoutSessionUseCases({
    required this.sessionRepository,
    required this.templateRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final ActiveSessionRepository sessionRepository;
  final WorkoutTemplateRepository templateRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<ActiveWorkoutSession?> findOpen() async {
    final session = await sessionRepository.findOpen();
    if (session == null) {
      return null;
    }
    if (_isPlayableResolvedTemplateSnapshot(
      session.resolvedTemplateSnapshotJson,
    )) {
      return session;
    }
    await abandon(session.metadata.id);
    return null;
  }

  Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
    final template = await templateRepository.findById(templateId);
    if (template == null) {
      throw const DomainException('Workout template not found.');
    }
    return _startFromTemplate(template);
  }

  Future<ActiveWorkoutSession> startFromLastTemplate() async {
    final open = await findOpen();
    if (open != null) {
      throw const DomainException('Active workout session already open.');
    }
    final templates = await templateRepository.listActive();
    final playable = [
      for (final template in templates)
        if (_isPlayableResolvedTemplateSnapshot(
          _resolvedTemplateSnapshotJson(template),
        ))
          template,
    ];
    if (playable.isEmpty) {
      throw const DomainException('No playable workout template found.');
    }
    playable.sort((left, right) {
      final leftStarted = left.lastStartedAt;
      final rightStarted = right.lastStartedAt;
      if (leftStarted != null && rightStarted != null) {
        return rightStarted.compareTo(leftStarted);
      }
      if (leftStarted != null) {
        return -1;
      }
      if (rightStarted != null) {
        return 1;
      }
      return left.name.compareTo(right.name);
    });
    return _startFromTemplate(playable.first);
  }

  Future<ActiveWorkoutSession> _startFromTemplate(
    WorkoutTemplate template,
  ) async {
    final resolvedTemplateSnapshotJson = _resolvedTemplateSnapshotJson(
      template,
    );
    _ensurePlayableResolvedTemplateSnapshot(resolvedTemplateSnapshotJson);
    final now = clock.now();
    final session = ActiveWorkoutSession(
      metadata: _newMetadata(ids, originDeviceId, now),
      sourceWorkoutTemplateId: template.metadata.id,
      status: ActiveWorkoutStatus.running,
      startedAt: now,
      lastPersistedAt: now,
      elapsedActiveMs: 0,
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
      resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
    );
    await sessionRepository.save(session);
    await _markTemplateStarted(template, now);
    return session;
  }

  Future<void> _markTemplateStarted(
    WorkoutTemplate template,
    DateTime startedAt,
  ) async {
    await templateRepository.save(
      WorkoutTemplate(
        metadata: template.metadata.touch(startedAt),
        name: template.name,
        lastStartedAt: startedAt,
        isExample: template.isExample,
        tags: template.tags,
        programs: template.programs,
        overrides: template.overrides,
      ),
    );
  }

  Future<ActiveWorkoutSession> startFromHistory(WorkoutHistory history) async {
    final snapshot =
        jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
    final resolvedTemplateSnapshotJson =
        snapshot['resolvedTemplateSnapshotJson'] as String;
    _ensurePlayableResolvedTemplateSnapshot(resolvedTemplateSnapshotJson);
    final now = clock.now();
    final session = ActiveWorkoutSession(
      metadata: _newMetadata(ids, originDeviceId, now),
      sourceWorkoutTemplateId: null,
      status: ActiveWorkoutStatus.running,
      startedAt: now,
      lastPersistedAt: now,
      elapsedActiveMs: 0,
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
      resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
    );
    await sessionRepository.save(session);
    return session;
  }

  Future<ActiveWorkoutSession> pause(String sessionId) async {
    final session = await _requiredSession(sessionId);
    final now = clock.now();
    final runningStopwatches =
        (await sessionRepository.listScoreStopwatchStates(
          sessionId,
        )).where((state) => state.status == ActiveScoreStopwatchStatus.running);
    for (final state in runningStopwatches) {
      final pausedStopwatch = state.copyWith(
        metadata: state.metadata.touch(now),
        status: ActiveScoreStopwatchStatus.paused,
        accumulatedMs: state.elapsedMillisecondsAt(now),
        stoppedAt: now,
      );
      await sessionRepository.saveScoreStopwatchState(pausedStopwatch);
    }
    final runningSetTimers = (await sessionRepository.listSetTimerStates(
      sessionId,
    )).where((state) => state.status == ActiveSetTimerStatus.running);
    for (final state in runningSetTimers) {
      final pausedSetTimer = state.copyWith(
        metadata: state.metadata.touch(now),
        status: ActiveSetTimerStatus.paused,
        startedAt: null,
        accumulatedMs: state.elapsedMillisecondsAt(now),
      );
      await sessionRepository.saveSetTimerState(pausedSetTimer);
    }
    final runningStepTimers =
        (await sessionRepository.listExerciseStepProgressStates(
          sessionId,
        )).where(
          (state) =>
              state.status == ActiveExerciseStepProgressStatus.runningTimer,
        );
    for (final state in runningStepTimers) {
      final pausedStep = state.copyWith(
        metadata: state.metadata.touch(now),
        status: ActiveExerciseStepProgressStatus.pausedTimer,
        accumulatedMs: state.elapsedMillisecondsAt(now),
        startedAt: null,
        lastTransitionAt: now,
      );
      await sessionRepository.saveExerciseStepProgressState(pausedStep);
    }
    final activeRests = (await sessionRepository.listRestStates(sessionId))
        .where(
          (rest) =>
              rest.endedAt == null &&
              rest.skippedAt == null &&
              rest.pausedAt == null,
        );
    for (final rest in activeRests) {
      await sessionRepository.saveRestState(
        rest.copyWith(metadata: rest.metadata.touch(now), pausedAt: now),
      );
    }
    final paused = session.pause(now);
    await sessionRepository.save(paused);
    return paused;
  }

  Future<ActiveWorkoutSession> resume(String sessionId) async {
    final session = await _requiredSession(sessionId);
    if (!_isPlayableResolvedTemplateSnapshot(
      session.resolvedTemplateSnapshotJson,
    )) {
      await abandon(sessionId);
      throw const DomainException(
        'Workout session does not contain any exercise.',
      );
    }
    final now = clock.now();
    final pausedSetTimers = (await sessionRepository.listSetTimerStates(
      sessionId,
    )).where((state) => state.status == ActiveSetTimerStatus.paused);
    for (final state in pausedSetTimers) {
      await sessionRepository.saveSetTimerState(
        state.copyWith(
          metadata: state.metadata.touch(now),
          status: ActiveSetTimerStatus.running,
          startedAt: now,
        ),
      );
    }
    final pausedStopwatches = (await sessionRepository.listScoreStopwatchStates(
      sessionId,
    )).where((state) => state.status == ActiveScoreStopwatchStatus.paused);
    for (final state in pausedStopwatches) {
      await sessionRepository.saveScoreStopwatchState(
        state.copyWith(
          metadata: state.metadata.touch(now),
          status: ActiveScoreStopwatchStatus.running,
          startedAt: now,
          stoppedAt: null,
        ),
      );
    }
    final sessionPausedAt = session.pausedAt;
    final pausedStepTimers =
        (await sessionRepository.listExerciseStepProgressStates(
          sessionId,
        )).where(
          (state) =>
              state.status == ActiveExerciseStepProgressStatus.pausedTimer &&
              sessionPausedAt != null &&
              state.lastTransitionAt.isAtSameMomentAs(sessionPausedAt),
        );
    for (final state in pausedStepTimers) {
      await sessionRepository.saveExerciseStepProgressState(
        state.copyWith(
          metadata: state.metadata.touch(now),
          status: ActiveExerciseStepProgressStatus.runningTimer,
          startedAt: now,
          lastTransitionAt: now,
        ),
      );
    }
    final pausedRests = (await sessionRepository.listRestStates(
      sessionId,
    )).where((rest) => rest.pausedAt != null);
    for (final rest in pausedRests) {
      final pausedAt = rest.pausedAt!;
      await sessionRepository.saveRestState(
        rest.copyWith(
          metadata: rest.metadata.touch(now),
          pausedAt: null,
          accumulatedPausedMs:
              rest.accumulatedPausedMs +
              now.difference(pausedAt).inMilliseconds,
        ),
      );
    }
    final resumed = session.resume(now);
    await sessionRepository.save(resumed);
    return resumed;
  }

  int elapsedActiveMilliseconds(ActiveWorkoutSession session) {
    return session.elapsedActiveMillisecondsAt(clock.now());
  }

  Future<SetExecutionTimerStartResult> startSetExecution({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final session = await _requiredSession(sessionId);
    final snapshot = _findExerciseSnapshot(
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
    );
    if (snapshot == null) {
      throw const DomainException('Exercise position not found in session.');
    }
    final now = clock.now();
    final setTimer = snapshot.timeEnabled
        ? await _startOrResumeSetTimer(
            sessionId: sessionId,
            programIndex: programIndex,
            exerciseIndex: exerciseIndex,
            setIndex: setIndex,
            now: now,
          )
        : null;
    final scoreStopwatch =
        snapshot.scoreEnabled &&
            snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch
        ? await _startScoreStopwatchForSetExecution(
            sessionId: sessionId,
            programIndex: programIndex,
            exerciseIndex: exerciseIndex,
            setIndex: setIndex,
            now: now,
          )
        : null;
    final stepProgress = await _startFirstStepTimerIfNeeded(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      snapshot: snapshot,
    );
    return SetExecutionTimerStartResult(
      setTimer: setTimer,
      scoreStopwatch: scoreStopwatch,
      stepProgress: stepProgress,
    );
  }

  Future<SetExecutionTimerStartResult> startCurrentExerciseTimers({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return startSetExecution(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  Future<ActiveSetTimerState?> stopSetExecutionTimers({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final now = clock.now();
    final setTimer = await _finishSetTimer(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      status: ActiveSetTimerStatus.stopped,
      now: now,
    );
    final scoreStopwatch = await sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (scoreStopwatch != null &&
        scoreStopwatch.status != ActiveScoreStopwatchStatus.stopped) {
      await sessionRepository.saveScoreStopwatchState(
        scoreStopwatch.copyWith(
          metadata: scoreStopwatch.metadata.touch(now),
          status: ActiveScoreStopwatchStatus.stopped,
          accumulatedMs: scoreStopwatch.elapsedMillisecondsAt(now),
          stoppedAt: now,
        ),
      );
    }
    final stepProgress = await sessionRepository.findExerciseStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (stepProgress?.status == ActiveExerciseStepProgressStatus.runningTimer) {
      await sessionRepository.saveExerciseStepProgressState(
        stepProgress!.copyWith(
          metadata: stepProgress.metadata.touch(now),
          status: ActiveExerciseStepProgressStatus.pausedTimer,
          startedAt: null,
          accumulatedMs: stepProgress.elapsedMillisecondsAt(now),
          lastTransitionAt: now,
        ),
      );
    }
    return setTimer;
  }

  Future<ActiveSetTimerState?> skipSetExecutionTimers({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final now = clock.now();
    final setTimer = await _finishSetTimer(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      status: ActiveSetTimerStatus.skipped,
      now: now,
    );
    final scoreStopwatch = await sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (scoreStopwatch != null &&
        scoreStopwatch.status != ActiveScoreStopwatchStatus.stopped) {
      await sessionRepository.saveScoreStopwatchState(
        scoreStopwatch.copyWith(
          metadata: scoreStopwatch.metadata.touch(now),
          status: ActiveScoreStopwatchStatus.stopped,
          accumulatedMs: scoreStopwatch.elapsedMillisecondsAt(now),
          stoppedAt: now,
        ),
      );
    }
    final stepProgress = await sessionRepository.findExerciseStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (stepProgress != null &&
        stepProgress.status !=
            ActiveExerciseStepProgressStatus.sequenceComplete) {
      try {
        await ActiveExerciseStepUseCases(
          sessionRepository: sessionRepository,
          clock: clock,
          ids: ids,
          originDeviceId: originDeviceId,
        ).skipSequence(
          sessionId: sessionId,
          programIndex: programIndex,
          exerciseIndex: exerciseIndex,
          setIndex: setIndex,
        );
      } on DomainException {
        // A set without configured steps has no step sequence to skip.
      }
    }
    return setTimer;
  }

  Future<ActiveSetResult> recordSetResult(ActiveSetResult result) async {
    await sessionRepository.saveSetResult(result);
    return result;
  }

  Future<ActiveSetResult> recordCurrentSetResult({
    required String sessionId,
    required String programSnapshotId,
    required String exerciseSnapshotId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    int? actualTimeMs,
    int? actualReps,
    double? actualScore,
    int? actualScoreTimeMs,
    ScoreInputMode scoreInputModeSnapshot = ScoreInputMode.manual,
    String? scoreLabelSnapshot,
    String? scoreUnitSnapshot,
  }) async {
    final now = clock.now();
    final resolvedActualScoreTimeMs = await _resolveStopwatchScoreTimeMs(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      scoreInputMode: scoreInputModeSnapshot,
      explicitActualScoreTimeMs: actualScoreTimeMs,
      now: now,
    );
    final resolvedActualScore = await _resolveManualScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      scoreInputMode: scoreInputModeSnapshot,
      explicitActualScore: actualScore,
    );
    final existingResults = await sessionRepository.listSetResults(sessionId);
    ActiveSetResult? existing;
    for (final result in existingResults) {
      if (result.programIndex == programIndex &&
          result.exerciseIndex == exerciseIndex &&
          result.setIndex == setIndex) {
        existing = result;
        break;
      }
    }
    final result = ActiveSetResult(
      metadata: existing == null
          ? _newMetadata(ids, originDeviceId, now)
          : existing.metadata.touch(now),
      activeWorkoutSessionId: sessionId,
      programSnapshotId: programSnapshotId,
      exerciseSnapshotId: exerciseSnapshotId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      startedAt: existing?.startedAt,
      completedAt: now,
      actualTimeMs: actualTimeMs,
      actualReps: actualReps,
      actualScore: resolvedActualScore,
      actualScoreTimeMs: resolvedActualScoreTimeMs,
      scoreInputModeSnapshot: scoreInputModeSnapshot,
      scoreLabelSnapshot: scoreLabelSnapshot,
      scoreUnitSnapshot: scoreUnitSnapshot,
      status: SetResultStatus.completed,
    );
    await sessionRepository.saveSetResult(result);
    await sessionRepository.deleteManualScoreState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      deletedAt: now,
    );
    return result;
  }

  Future<ActiveSetResult> upsertSetResultAtPosition({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required SetResultStatus status,
    int? actualTimeMs,
    int? actualReps,
    double? actualScore,
    int? actualScoreTimeMs,
    ScoreInputMode? scoreInputModeSnapshot,
    String? scoreLabelSnapshot,
    String? scoreUnitSnapshot,
    String? note,
  }) async {
    final session = await _requiredSession(sessionId);
    _ensureEditablePastPosition(
      session: session,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final snapshot = _findSetSnapshot(
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (snapshot == null) {
      throw const DomainException(
        'Set position not found in session snapshot.',
      );
    }
    final existingResults = await sessionRepository.listSetResults(sessionId);
    ActiveSetResult? existing;
    for (final result in existingResults) {
      if (result.programIndex == programIndex &&
          result.exerciseIndex == exerciseIndex &&
          result.setIndex == setIndex) {
        existing = result;
        break;
      }
    }
    final now = clock.now();
    final isSkipped = status == SetResultStatus.skipped;
    final resolvedScoreInputMode =
        scoreInputModeSnapshot ?? snapshot.scoreInputModeSnapshot;
    final upserted = ActiveSetResult(
      metadata: existing == null
          ? _newMetadata(ids, originDeviceId, now)
          : existing.metadata.touch(now),
      activeWorkoutSessionId: sessionId,
      programSnapshotId: snapshot.programSnapshotId,
      exerciseSnapshotId: snapshot.exerciseSnapshotId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      startedAt: existing?.startedAt,
      completedAt: isSkipped ? null : now,
      actualTimeMs: isSkipped ? null : actualTimeMs,
      actualReps: isSkipped ? null : actualReps,
      actualScore: isSkipped ? null : actualScore,
      actualScoreTimeMs: isSkipped ? null : actualScoreTimeMs,
      scoreInputModeSnapshot: resolvedScoreInputMode,
      scoreLabelSnapshot: isSkipped
          ? null
          : scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot,
      scoreUnitSnapshot: isSkipped
          ? null
          : scoreUnitSnapshot ?? snapshot.scoreUnitSnapshot,
      note: note,
      status: status,
    );
    await sessionRepository.saveSetResult(upserted);
    return upserted;
  }

  Future<ActiveScoreStopwatchState> startScoreStopwatch({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    await _requiredSession(sessionId);
    final now = clock.now();
    final existing = await sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (existing != null) {
      if (existing.status == ActiveScoreStopwatchStatus.running) {
        return existing;
      }
      final resumed = existing.copyWith(
        metadata: existing.metadata.touch(now),
        status: ActiveScoreStopwatchStatus.running,
        startedAt: now,
        stoppedAt: null,
      );
      await sessionRepository.saveScoreStopwatchState(resumed);
      return resumed;
    }
    final state = ActiveScoreStopwatchState(
      metadata: _newMetadata(ids, originDeviceId, now),
      activeWorkoutSessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      status: ActiveScoreStopwatchStatus.running,
      startedAt: now,
      accumulatedMs: 0,
    );
    await sessionRepository.saveScoreStopwatchState(state);
    return state;
  }

  Future<ActiveScoreStopwatchState> stopScoreStopwatch({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final state = await _requiredStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (state.status == ActiveScoreStopwatchStatus.stopped) {
      return state;
    }
    final now = clock.now();
    final stopped = state.copyWith(
      metadata: state.metadata.touch(now),
      status: ActiveScoreStopwatchStatus.stopped,
      accumulatedMs: state.elapsedMillisecondsAt(now),
      stoppedAt: now,
    );
    await sessionRepository.saveScoreStopwatchState(stopped);
    return stopped;
  }

  Future<ActiveScoreStopwatchState> resumeScoreStopwatch({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return startScoreStopwatch(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  Future<void> resetScoreStopwatch({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    await sessionRepository.deleteScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      deletedAt: clock.now(),
    );
  }

  Future<ActiveScoreStopwatchState?> findScoreStopwatch({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  Future<ManualScoreUpdateResult> incrementManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return _updateManualScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      delta: 1,
    );
  }

  Future<ManualScoreUpdateResult> decrementManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return _updateManualScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      delta: -1,
    );
  }

  Future<ManualScoreUpdateResult> setManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required double value,
  }) {
    return _writeManualScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      value: value.clamp(0, double.infinity).toDouble(),
    );
  }

  Future<ActiveManualScoreState?> findManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return sessionRepository.findManualScoreState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  int scoreStopwatchElapsedMilliseconds(ActiveScoreStopwatchState state) {
    return state.elapsedMillisecondsAt(clock.now());
  }

  Future<ActiveSetTimerState?> findSetTimer({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return sessionRepository.findSetTimerState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  int setTimerElapsedMilliseconds(ActiveSetTimerState state) {
    return state.elapsedMillisecondsAt(clock.now());
  }

  Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
    final session = await _requiredSession(sessionId);
    final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
    final results = await sessionRepository.listSetResults(sessionId);
    final resultsByPosition = {
      for (final result in results)
        _positionKey(
          result.programIndex,
          result.exerciseIndex,
          result.setIndex,
        ): result,
    };
    return snapshots.map((snapshot) {
      final result =
          resultsByPosition[_positionKey(
            snapshot.programIndex,
            snapshot.exerciseIndex,
            snapshot.setIndex,
          )];
      return SetResultPositionState(
        programIndex: snapshot.programIndex,
        exerciseIndex: snapshot.exerciseIndex,
        setIndex: snapshot.setIndex,
        status: result == null
            ? SetPositionStatus.pending
            : switch (result.status) {
                SetResultStatus.completed => SetPositionStatus.completed,
                SetResultStatus.skipped => SetPositionStatus.skipped,
              },
        result: result,
      );
    }).toList();
  }

  Future<ActiveWorkoutSession> updateProgress({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final session = await _requiredSession(sessionId);
    final updated = session.copyWith(
      currentProgramIndex: programIndex,
      currentExerciseIndex: exerciseIndex,
      currentSetIndex: setIndex,
      metadata: session.metadata.touch(clock.now()),
    );
    await sessionRepository.save(updated);
    return updated;
  }

  Future<ActiveWorkoutSession> quitAndSave(String sessionId) async {
    final session = await _requiredSession(sessionId);
    final now = clock.now();
    final saved = session.copyWith(
      status: ActiveWorkoutStatus.savedExit,
      lastPersistedAt: now,
      elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
      metadata: session.metadata.touch(now),
    );
    await sessionRepository.save(saved);
    return saved;
  }

  Future<ActiveWorkoutSession> abandon(String sessionId) async {
    final session = await _requiredSession(sessionId);
    final now = clock.now();
    final abandoned = session.copyWith(
      status: ActiveWorkoutStatus.abandoned,
      endedAt: now,
      lastPersistedAt: now,
      elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
      metadata: session.metadata.touch(now),
    );
    await sessionRepository.save(abandoned);
    return abandoned;
  }

  Future<ActiveWorkoutSession> complete(String sessionId) async {
    final session = await _requiredSession(sessionId);
    final now = clock.now();
    final completed = session.copyWith(
      status: ActiveWorkoutStatus.completed,
      endedAt: now,
      lastPersistedAt: now,
      elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
      metadata: session.metadata.touch(now),
    );
    await sessionRepository.save(completed);
    return completed;
  }

  Future<ActiveRestState> startRestAfterSet({
    required String sessionId,
    required int afterProgramIndex,
    required int afterExerciseIndex,
    required int afterSetIndex,
    required int plannedRestSeconds,
    int? adjustedRestSeconds,
  }) async {
    final now = clock.now();
    final rest = ActiveRestState(
      metadata: _newMetadata(ids, originDeviceId, now),
      activeWorkoutSessionId: sessionId,
      afterProgramIndex: afterProgramIndex,
      afterExerciseIndex: afterExerciseIndex,
      afterSetIndex: afterSetIndex,
      plannedRestSeconds: plannedRestSeconds,
      adjustedRestSeconds: adjustedRestSeconds ?? plannedRestSeconds,
      startedAt: now,
    );
    await sessionRepository.saveRestState(rest);
    return rest;
  }

  Future<ActiveRestState> adjustRestSeconds({
    required String restStateId,
    required int deltaSeconds,
  }) async {
    final rest = await sessionRepository.findRestStateById(restStateId);
    if (rest == null) {
      throw const DomainException('Active rest state not found.');
    }
    final now = clock.now();
    final adjusted = rest.copyWith(
      metadata: rest.metadata.touch(now),
      adjustedRestSeconds: (rest.adjustedRestSeconds + deltaSeconds).clamp(
        0,
        1 << 31,
      ),
    );
    await sessionRepository.saveRestState(adjusted);
    return adjusted;
  }

  Future<ActiveRestState> skipRest({required String restStateId}) async {
    final rest = await sessionRepository.findRestStateById(restStateId);
    if (rest == null) {
      throw const DomainException('Active rest state not found.');
    }
    final now = clock.now();
    final skipped = rest.copyWith(
      metadata: rest.metadata.touch(now),
      skippedAt: now,
    );
    await sessionRepository.saveRestState(skipped);
    return skipped;
  }

  Future<ActiveRestState?> findActiveRest({required String sessionId}) async {
    final restStates = await sessionRepository.listRestStates(sessionId);
    final active =
        restStates
            .where((rest) => rest.endedAt == null && rest.skippedAt == null)
            .toList()
          ..sort((left, right) => right.startedAt.compareTo(left.startedAt));
    return active.isEmpty ? null : active.first;
  }

  Future<ActiveSetTimerState> _startOrResumeSetTimer({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required DateTime now,
  }) async {
    final existing = await sessionRepository.findSetTimerState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (existing != null) {
      if (existing.status == ActiveSetTimerStatus.running ||
          existing.status == ActiveSetTimerStatus.stopped ||
          existing.status == ActiveSetTimerStatus.skipped) {
        return existing;
      }
      final resumed = existing.copyWith(
        metadata: existing.metadata.touch(now),
        status: ActiveSetTimerStatus.running,
        startedAt: now,
        stoppedAt: null,
        skippedAt: null,
      );
      await sessionRepository.saveSetTimerState(resumed);
      return resumed;
    }
    final state = ActiveSetTimerState(
      metadata: _newMetadata(ids, originDeviceId, now),
      activeWorkoutSessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      status: ActiveSetTimerStatus.running,
      startedAt: now,
      accumulatedMs: 0,
    );
    await sessionRepository.saveSetTimerState(state);
    return state;
  }

  Future<ActiveSetTimerState?> _finishSetTimer({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required ActiveSetTimerStatus status,
    required DateTime now,
  }) async {
    final existing = await sessionRepository.findSetTimerState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (existing == null) {
      return null;
    }
    if (existing.status == status) {
      return existing;
    }
    final finished = existing.copyWith(
      metadata: existing.metadata.touch(now),
      status: status,
      startedAt: null,
      accumulatedMs: status == ActiveSetTimerStatus.skipped
          ? existing.accumulatedMs
          : existing.elapsedMillisecondsAt(now),
      stoppedAt: status == ActiveSetTimerStatus.stopped ? now : null,
      skippedAt: status == ActiveSetTimerStatus.skipped ? now : null,
    );
    await sessionRepository.saveSetTimerState(finished);
    return finished;
  }

  Future<ActiveScoreStopwatchState> _startScoreStopwatchForSetExecution({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required DateTime now,
  }) async {
    final existing = await sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (existing != null) {
      if (existing.status == ActiveScoreStopwatchStatus.running ||
          existing.status == ActiveScoreStopwatchStatus.stopped) {
        return existing;
      }
      final resumed = existing.copyWith(
        metadata: existing.metadata.touch(now),
        status: ActiveScoreStopwatchStatus.running,
        startedAt: now,
        stoppedAt: null,
      );
      await sessionRepository.saveScoreStopwatchState(resumed);
      return resumed;
    }
    final state = ActiveScoreStopwatchState(
      metadata: _newMetadata(ids, originDeviceId, now),
      activeWorkoutSessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      status: ActiveScoreStopwatchStatus.running,
      startedAt: now,
      accumulatedMs: 0,
    );
    await sessionRepository.saveScoreStopwatchState(state);
    return state;
  }

  Future<ActiveExerciseStepProgressState?> _startFirstStepTimerIfNeeded({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required _ResolvedExerciseSnapshot snapshot,
  }) async {
    if (snapshot.steps.isEmpty ||
        snapshot.steps.first.type != ExerciseStepType.time) {
      return null;
    }
    final existing = await sessionRepository.findExerciseStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (existing != null &&
        (existing.currentPassageIndex != 0 || existing.currentStepIndex != 0)) {
      return existing;
    }
    return ActiveExerciseStepUseCases(
      sessionRepository: sessionRepository,
      clock: clock,
      ids: ids,
      originDeviceId: originDeviceId,
    ).startTimer(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  Future<ManualScoreUpdateResult> _updateManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required int delta,
  }) async {
    await _ensureManualScoreActive(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
    );
    final existing = await sessionRepository.findManualScoreState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final currentValue = existing?.value ?? 0;
    final nextValue = (currentValue + delta).clamp(0, double.infinity);
    return _writeManualScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      value: nextValue.toDouble(),
      existing: existing,
      validate: false,
    );
  }

  Future<void> _ensureManualScoreActive({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
  }) async {
    final session = await _requiredSession(sessionId);
    final snapshot = _findExerciseSnapshot(
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
    );
    if (snapshot == null ||
        !snapshot.scoreEnabled ||
        snapshot.scoreInputModeSnapshot != ScoreInputMode.manual) {
      throw const DomainException('Manual score is not active for this set.');
    }
  }

  Future<ManualScoreUpdateResult> _writeManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required double value,
    ActiveManualScoreState? existing,
    bool validate = true,
  }) async {
    if (validate) {
      await _ensureManualScoreActive(
        sessionId: sessionId,
        programIndex: programIndex,
        exerciseIndex: exerciseIndex,
      );
    }
    final now = clock.now();
    existing ??= await sessionRepository.findManualScoreState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final changed = existing == null ? value != 0 : value != existing.value;
    if (existing == null) {
      final state = ActiveManualScoreState(
        metadata: _newMetadata(ids, originDeviceId, now),
        activeWorkoutSessionId: sessionId,
        programIndex: programIndex,
        exerciseIndex: exerciseIndex,
        setIndex: setIndex,
        value: value,
        updatedAt: now,
      );
      await sessionRepository.saveManualScoreState(state);
      return ManualScoreUpdateResult(state: state, changed: changed);
    }
    if (!changed) {
      return ManualScoreUpdateResult(state: existing, changed: false);
    }
    final updated = existing.copyWith(
      metadata: existing.metadata.touch(now),
      value: value,
      updatedAt: now,
    );
    await sessionRepository.saveManualScoreState(updated);
    return ManualScoreUpdateResult(state: updated, changed: true);
  }

  Future<ActiveWorkoutSession> _requiredSession(String sessionId) async {
    final session = await sessionRepository.findById(sessionId);
    if (session == null) {
      throw const DomainException('Active workout session not found.');
    }
    return session;
  }

  Future<ActiveScoreStopwatchState> _requiredStopwatchState({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final state = await sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (state == null) {
      throw const DomainException('Active score stopwatch state not found.');
    }
    return state;
  }

  Future<int?> _resolveStopwatchScoreTimeMs({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required ScoreInputMode scoreInputMode,
    required int? explicitActualScoreTimeMs,
    required DateTime now,
  }) async {
    if (scoreInputMode != ScoreInputMode.stopwatch) {
      return explicitActualScoreTimeMs;
    }
    final state = await sessionRepository.findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (state == null) {
      return explicitActualScoreTimeMs;
    }
    if (state.status == ActiveScoreStopwatchStatus.stopped) {
      return explicitActualScoreTimeMs ?? state.accumulatedMs;
    }
    final stopped = state.copyWith(
      metadata: state.metadata.touch(now),
      status: ActiveScoreStopwatchStatus.stopped,
      accumulatedMs: state.elapsedMillisecondsAt(now),
      stoppedAt: now,
    );
    await sessionRepository.saveScoreStopwatchState(stopped);
    return explicitActualScoreTimeMs ?? stopped.accumulatedMs;
  }

  Future<double?> _resolveManualScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required ScoreInputMode scoreInputMode,
    required double? explicitActualScore,
  }) async {
    if (scoreInputMode != ScoreInputMode.manual ||
        explicitActualScore != null) {
      return explicitActualScore;
    }
    final state = await sessionRepository.findManualScoreState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    return state?.value;
  }
}

final class ActiveExerciseStepProgressView {
  const ActiveExerciseStepProgressView({
    required this.state,
    required this.steps,
    required this.expectedPassages,
    required this.results,
  });

  final ActiveExerciseStepProgressState state;
  final List<ExerciseStep> steps;
  final int expectedPassages;
  final List<ActiveExerciseStepResult> results;

  ExerciseStep? get currentStep =>
      state.status == ActiveExerciseStepProgressStatus.sequenceComplete
      ? null
      : steps[state.currentStepIndex];
}

final class WatchCompanionProjectionUseCases implements WatchProjectionSource {
  WatchCompanionProjectionUseCases({
    required ActiveSessionRepository sessionRepository,
    required Clock clock,
    required IdGenerator ids,
    required String originDeviceId,
    WatchProjectionPublisher? publisher,
  }) : _projector = WatchSessionProjectionProjector(
         sessionRepository: sessionRepository,
         clock: clock,
         ids: ids,
         originDeviceId: originDeviceId,
       ),
       _publisher = publisher;

  final WatchSessionProjectionProjector _projector;
  final WatchProjectionPublisher? _publisher;
  final _controller = StreamController<WatchSessionProjection>.broadcast();
  WatchSessionProjection? _latestProjection;
  int _revision = 0;

  @override
  Stream<WatchSessionProjection> get projections => _controller.stream;

  @override
  Future<WatchSessionProjection> currentProjection() async {
    return _latestProjection ?? _projectWithCurrentRevision();
  }

  @override
  Future<WatchSessionProjection> emitCurrentProjection() async {
    var projection = await _projector.project(revision: _revision);
    if (_latestProjection == null ||
        !_hasSameWatchCommandRevisionState(_latestProjection!, projection)) {
      _revision += 1;
      projection = await _projector.project(revision: _revision);
    }
    _latestProjection = projection;
    _controller.add(projection);
    await _publisher?.publish(projection);
    return projection;
  }

  Future<void> dispose() => _controller.close();

  Future<WatchSessionProjection> _projectWithCurrentRevision() {
    return _projector.project(revision: _revision);
  }
}

bool _hasSameWatchCommandRevisionState(
  WatchSessionProjection left,
  WatchSessionProjection right,
) {
  return left.schemaVersion == right.schemaVersion &&
      left.deviceSessionId == right.deviceSessionId &&
      left.phase == right.phase &&
      left.phoneReachable == right.phoneReachable &&
      left.seriesIndex == right.seriesIndex &&
      left.seriesTotal == right.seriesTotal &&
      left.exerciseName == right.exerciseName &&
      left.programIndex == right.programIndex &&
      left.exerciseIndex == right.exerciseIndex &&
      left.setIndex == right.setIndex &&
      left.passageIndex == right.passageIndex &&
      left.passageTotal == right.passageTotal &&
      left.stepIndex == right.stepIndex &&
      left.stepTotal == right.stepTotal &&
      left.stepName == right.stepName &&
      _hasSameWatchCommandRevisionTimerState(
        left.dominantTimer,
        right.dominantTimer,
      ) &&
      _hasSameWatchCommandRevisionTimerListState(
        left.secondaryTimers,
        right.secondaryTimers,
      ) &&
      left.primaryAction == right.primaryAction &&
      _listEquals(left.secondaryActions, right.secondaryActions) &&
      left.nextExerciseName == right.nextExerciseName &&
      left.statusLabel == right.statusLabel &&
      left.hasManualScore == right.hasManualScore &&
      left.currentManualScoreValue == right.currentManualScoreValue &&
      left.canDecrementScore == right.canDecrementScore &&
      left.manualScoreTargetValue == right.manualScoreTargetValue &&
      left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
      left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
      left.manualScoreScope == right.manualScoreScope &&
      _listEquals(
        left.healthServicesExerciseTypeStrategy,
        right.healthServicesExerciseTypeStrategy,
      );
}

bool _hasSameWatchCommandRevisionTimerListState(
  List<WatchTimerProjection> left,
  List<WatchTimerProjection> right,
) {
  if (left.length != right.length) {
    return false;
  }
  for (var index = 0; index < left.length; index += 1) {
    if (!_hasSameWatchCommandRevisionTimerState(left[index], right[index])) {
      return false;
    }
  }
  return true;
}

bool _hasSameWatchCommandRevisionTimerState(
  WatchTimerProjection? left,
  WatchTimerProjection? right,
) {
  if (left == null || right == null) {
    return left == right;
  }
  return left.kind == right.kind &&
      left.label == right.label &&
      left.displayMode == right.displayMode &&
      left.runState == right.runState &&
      left.targetMs == right.targetMs;
}

bool _listEquals<T>(List<T> left, List<T> right) {
  if (left.length != right.length) {
    return false;
  }
  for (var index = 0; index < left.length; index += 1) {
    if (left[index] != right[index]) {
      return false;
    }
  }
  return true;
}

final class WatchCompanionCommandHandler implements WatchCommandIngress {
  WatchCompanionCommandHandler({
    required ActiveSessionRepository sessionRepository,
    required ActiveWorkoutSessionUseCases activeSessionUseCases,
    required ActiveExerciseStepUseCases stepUseCases,
    required WatchProjectionSource projectionSource,
  }) : _sessionRepository = sessionRepository,
       _activeSessionUseCases = activeSessionUseCases,
       _stepUseCases = stepUseCases,
       _projectionSource = projectionSource;

  final ActiveSessionRepository _sessionRepository;
  final ActiveWorkoutSessionUseCases _activeSessionUseCases;
  final ActiveExerciseStepUseCases _stepUseCases;
  final WatchProjectionSource _projectionSource;
  final _handledCommands = <_WatchCommandKey, WatchCommandAck>{};
  Future<void> _tail = Future<void>.value();

  @override
  Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
    final run = _tail.then(
      (_) => _dispatch(command),
      onError: (_) => _dispatch(command),
    );
    _tail = run.then((_) {}, onError: (_) {});
    return run;
  }

  Future<WatchCommandAck> _dispatch(WatchCommandEnvelope command) async {
    final key = _WatchCommandKey(command);
    final previousAck = _handledCommands[key];
    if (previousAck == WatchCommandAck.accepted ||
        previousAck == WatchCommandAck.acceptedNoOp) {
      return WatchCommandAck.acceptedNoOp;
    }

    try {
      final projection = await _projectionSource.currentProjection();
      if (projection.phase == WatchSessionPhase.noActiveSession ||
          projection.deviceSessionId.isEmpty) {
        return WatchCommandAck.rejectedNoActiveSession;
      }
      if (command.sessionId != projection.deviceSessionId) {
        return WatchCommandAck.rejectedSessionMismatch;
      }
      final applicable = _isApplicable(command.type, projection);
      if (!_allowsStaleRevision(command.type) &&
          command.expectedRevision != projection.revision) {
        return WatchCommandAck.rejectedStaleRevision;
      }
      if (!applicable) {
        return WatchCommandAck.rejectedNotApplicable;
      }

      final session = await _sessionRepository.findOpen();
      if (session == null ||
          session.status == ActiveWorkoutStatus.completed ||
          session.status == ActiveWorkoutStatus.abandoned ||
          session.status == ActiveWorkoutStatus.savedExit) {
        return WatchCommandAck.rejectedNoActiveSession;
      }
      if (session.metadata.id != command.sessionId) {
        return WatchCommandAck.rejectedSessionMismatch;
      }

      final ack = await _route(command.type, session, projection);
      if (ack == WatchCommandAck.accepted ||
          ack == WatchCommandAck.acceptedNoOp) {
        _handledCommands[key] = ack;
      }
      if (ack == WatchCommandAck.accepted) {
        await _emitProjectionAfterCommand();
      }
      return ack;
    } on DomainException {
      return WatchCommandAck.rejectedNotApplicable;
    } on StateError {
      return WatchCommandAck.rejectedNotApplicable;
    } on Exception {
      return WatchCommandAck.rejectedPhoneBusy;
    }
  }

  bool _isApplicable(WatchCommandType type, WatchSessionProjection projection) {
    return switch (type) {
      WatchCommandType.startCurrentExercise =>
        projection.primaryAction == WatchPrimaryAction.startCurrentExercise,
      WatchCommandType.pauseSession =>
        projection.primaryAction == WatchPrimaryAction.pauseSession,
      WatchCommandType.resumeSession =>
        projection.primaryAction == WatchPrimaryAction.resumeSession,
      WatchCommandType.startPreparedTimedStep =>
        projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep,
      WatchCommandType.completeCurrentStep =>
        projection.stepType == WatchStepType.reps &&
            projection.stepTargetValue != null,
      WatchCommandType.skipCurrentStep => projection.secondaryActions.contains(
        WatchSecondaryAction.skipCurrentStep,
      ),
      WatchCommandType.skipCurrentPassage =>
        projection.secondaryActions.contains(
          WatchSecondaryAction.skipCurrentPassage,
        ),
      WatchCommandType.finishCurrentSet => projection.secondaryActions.contains(
        WatchSecondaryAction.finishCurrentSet,
      ),
      WatchCommandType.skipCurrentSet => projection.secondaryActions.contains(
        WatchSecondaryAction.skipCurrentSet,
      ),
      WatchCommandType.skipCurrentRest =>
        projection.primaryAction == WatchPrimaryAction.skipCurrentRest ||
            projection.secondaryActions.contains(
              WatchSecondaryAction.skipCurrentRest,
            ),
      WatchCommandType.incrementScore =>
        projection.hasManualScore && projection.manualScoreScope != null,
      WatchCommandType.decrementScore =>
        projection.hasManualScore && projection.manualScoreScope != null,
    };
  }

  bool _allowsStaleRevision(WatchCommandType type) {
    return type == WatchCommandType.incrementScore ||
        type == WatchCommandType.decrementScore;
  }

  Future<WatchCommandAck> _route(
    WatchCommandType type,
    ActiveWorkoutSession session,
    WatchSessionProjection projection,
  ) {
    return switch (type) {
      WatchCommandType.startCurrentExercise => _startCurrentExercise(session),
      WatchCommandType.pauseSession => _pause(session),
      WatchCommandType.resumeSession => _resume(session),
      WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep(
        session,
      ),
      WatchCommandType.completeCurrentStep => _completeCurrentStep(session),
      WatchCommandType.skipCurrentStep => _skipCurrentStep(session),
      WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session),
      WatchCommandType.finishCurrentSet => _finishCurrentSet(
        session,
        skipped: false,
      ),
      WatchCommandType.skipCurrentSet => _finishCurrentSet(
        session,
        skipped: true,
      ),
      WatchCommandType.skipCurrentRest => _skipCurrentRest(session),
      WatchCommandType.incrementScore => _incrementScore(session, projection),
      WatchCommandType.decrementScore => _decrementScore(session, projection),
    };
  }

  Future<WatchCommandAck> _startCurrentExercise(
    ActiveWorkoutSession session,
  ) async {
    final result = await _activeSessionUseCases.startCurrentExerciseTimers(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    final changed =
        result.setTimer != null ||
        result.scoreStopwatch != null ||
        result.stepProgress != null;
    return changed ? WatchCommandAck.accepted : WatchCommandAck.acceptedNoOp;
  }

  Future<WatchCommandAck> _pause(ActiveWorkoutSession session) async {
    await _activeSessionUseCases.pause(session.metadata.id);
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _resume(ActiveWorkoutSession session) async {
    await _activeSessionUseCases.resume(session.metadata.id);
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _startPreparedTimedStep(
    ActiveWorkoutSession session,
  ) async {
    await _stepUseCases.startTimer(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _skipCurrentStep(ActiveWorkoutSession session) async {
    await _stepUseCases.skipCurrentStep(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _completeCurrentStep(
    ActiveWorkoutSession session,
  ) async {
    await _stepUseCases.completeCurrentStep(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _skipCurrentPassage(
    ActiveWorkoutSession session,
  ) async {
    await _stepUseCases.skipCurrentPassage(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _finishCurrentSet(
    ActiveWorkoutSession session, {
    required bool skipped,
  }) async {
    final snapshot = _findExerciseSnapshot(
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
    );
    if (snapshot == null) {
      return WatchCommandAck.rejectedNotApplicable;
    }
    final setTimer = skipped
        ? await _activeSessionUseCases.skipSetExecutionTimers(
            sessionId: session.metadata.id,
            programIndex: session.currentProgramIndex,
            exerciseIndex: session.currentExerciseIndex,
            setIndex: session.currentSetIndex,
          )
        : await _activeSessionUseCases.stopSetExecutionTimers(
            sessionId: session.metadata.id,
            programIndex: session.currentProgramIndex,
            exerciseIndex: session.currentExerciseIndex,
            setIndex: session.currentSetIndex,
          );
    final actualScoreTimeMs = skipped
        ? null
        : await _scoreStopwatchMsIfNeeded(session, snapshot);
    await _activeSessionUseCases.recordCurrentSetResult(
      sessionId: session.metadata.id,
      programSnapshotId: snapshot.programSnapshotId,
      exerciseSnapshotId: snapshot.exerciseSnapshotId,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
      actualTimeMs: skipped || !snapshot.timeEnabled
          ? null
          : setTimer?.accumulatedMs,
      actualReps: skipped || !snapshot.repsEnabled ? null : snapshot.targetReps,
      actualScoreTimeMs: actualScoreTimeMs,
      scoreInputModeSnapshot: snapshot.scoreInputModeSnapshot,
      scoreLabelSnapshot: snapshot.scoreLabelSnapshot,
      scoreUnitSnapshot: snapshot.scoreUnitSnapshot,
    );
    await _advanceAfterSet(session, snapshot);
    return WatchCommandAck.accepted;
  }

  Future<int?> _scoreStopwatchMsIfNeeded(
    ActiveWorkoutSession session,
    _ResolvedExerciseSnapshot snapshot,
  ) async {
    if (!snapshot.scoreEnabled ||
        snapshot.scoreInputModeSnapshot != ScoreInputMode.stopwatch) {
      return null;
    }
    final state = await _sessionRepository.findScoreStopwatchState(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    return state?.accumulatedMs;
  }

  Future<void> _advanceAfterSet(
    ActiveWorkoutSession session,
    _ResolvedExerciseSnapshot snapshot,
  ) async {
    final next = _nextPosition(session.resolvedTemplateSnapshotJson, session);
    if (next == null) {
      await _activeSessionUseCases.complete(session.metadata.id);
      return;
    }
    if (snapshot.restSeconds > 0) {
      await _activeSessionUseCases.startRestAfterSet(
        sessionId: session.metadata.id,
        afterProgramIndex: session.currentProgramIndex,
        afterExerciseIndex: session.currentExerciseIndex,
        afterSetIndex: session.currentSetIndex,
        plannedRestSeconds: snapshot.restSeconds,
      );
      return;
    }
    await _activeSessionUseCases.updateProgress(
      sessionId: session.metadata.id,
      programIndex: next.programIndex,
      exerciseIndex: next.exerciseIndex,
      setIndex: next.setIndex,
    );
  }

  Future<WatchCommandAck> _skipCurrentRest(ActiveWorkoutSession session) async {
    final rest = await _activeSessionUseCases.findActiveRest(
      sessionId: session.metadata.id,
    );
    if (rest == null) {
      return WatchCommandAck.acceptedNoOp;
    }
    await _activeSessionUseCases.skipRest(restStateId: rest.metadata.id);
    final next = _nextPositionAfter(
      session.resolvedTemplateSnapshotJson,
      programIndex: rest.afterProgramIndex,
      exerciseIndex: rest.afterExerciseIndex,
      setIndex: rest.afterSetIndex,
    );
    if (next == null) {
      await _activeSessionUseCases.complete(session.metadata.id);
    } else {
      await _activeSessionUseCases.updateProgress(
        sessionId: session.metadata.id,
        programIndex: next.programIndex,
        exerciseIndex: next.exerciseIndex,
        setIndex: next.setIndex,
      );
    }
    return WatchCommandAck.accepted;
  }

  Future<WatchCommandAck> _incrementScore(
    ActiveWorkoutSession session,
    WatchSessionProjection projection,
  ) async {
    return _updateScore(session, projection, increment: true);
  }

  Future<WatchCommandAck> _decrementScore(
    ActiveWorkoutSession session,
    WatchSessionProjection projection,
  ) async {
    return _updateScore(session, projection, increment: false);
  }

  Future<WatchCommandAck> _updateScore(
    ActiveWorkoutSession session,
    WatchSessionProjection projection, {
    required bool increment,
  }) async {
    final changed = switch (projection.manualScoreScope) {
      WatchManualScoreScope.series => await _updateSeriesScore(
        session,
        increment: increment,
      ),
      WatchManualScoreScope.step => await _updateIndependentStepScore(
        session,
        increment: increment,
      ),
      null => throw const DomainException('Manual score scope is missing.'),
    };
    return changed ? WatchCommandAck.accepted : WatchCommandAck.acceptedNoOp;
  }

  Future<bool> _updateSeriesScore(
    ActiveWorkoutSession session, {
    required bool increment,
  }) async {
    final result = increment
        ? await _activeSessionUseCases.incrementManualScore(
            sessionId: session.metadata.id,
            programIndex: session.currentProgramIndex,
            exerciseIndex: session.currentExerciseIndex,
            setIndex: session.currentSetIndex,
          )
        : await _activeSessionUseCases.decrementManualScore(
            sessionId: session.metadata.id,
            programIndex: session.currentProgramIndex,
            exerciseIndex: session.currentExerciseIndex,
            setIndex: session.currentSetIndex,
          );
    return result.changed;
  }

  Future<bool> _updateIndependentStepScore(
    ActiveWorkoutSession session, {
    required bool increment,
  }) {
    return increment
        ? _stepUseCases.incrementCurrentIndependentStepScore(
            sessionId: session.metadata.id,
            programIndex: session.currentProgramIndex,
            exerciseIndex: session.currentExerciseIndex,
            setIndex: session.currentSetIndex,
          )
        : _stepUseCases.decrementCurrentIndependentStepScore(
            sessionId: session.metadata.id,
            programIndex: session.currentProgramIndex,
            exerciseIndex: session.currentExerciseIndex,
            setIndex: session.currentSetIndex,
          );
  }

  Future<void> _emitProjectionAfterCommand() async {
    try {
      await _projectionSource.emitCurrentProjection();
    } on Exception {
      // The command has already been applied; a publish failure must not turn
      // the watch retry path into a second mutation.
    }
  }
}

final class _WatchCommandKey {
  _WatchCommandKey(WatchCommandEnvelope command)
    : sessionId = command.sessionId,
      expectedRevision = command.expectedRevision,
      commandId = command.commandId,
      type = command.type;

  final String sessionId;
  final int expectedRevision;
  final String commandId;
  final WatchCommandType type;

  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is _WatchCommandKey &&
            sessionId == other.sessionId &&
            expectedRevision == other.expectedRevision &&
            commandId == other.commandId &&
            type == other.type;
  }

  @override
  int get hashCode => Object.hash(sessionId, expectedRevision, commandId, type);
}

final class WatchSessionProjectionProjector {
  const WatchSessionProjectionProjector({
    required this.sessionRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final ActiveSessionRepository sessionRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<WatchSessionProjection> project({required int revision}) async {
    final now = clock.now();
    final session = await sessionRepository.findOpen();
    if (session == null ||
        session.status == ActiveWorkoutStatus.completed ||
        session.status == ActiveWorkoutStatus.abandoned ||
        session.status == ActiveWorkoutStatus.savedExit) {
      return WatchSessionProjection(
        deviceSessionId: '',
        revision: revision,
        projectedAtEpochMs: _epochMs(now),
        expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
        phase: WatchSessionPhase.noActiveSession,
        phoneReachable: true,
        seriesIndex: 0,
        seriesTotal: 0,
        exerciseName: '',
        primaryAction: WatchPrimaryAction.none,
        statusLabel: 'Aucune séance en cours',
      );
    }

    final snapshot = _findExerciseSnapshot(
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
    );
    if (snapshot == null) {
      return WatchSessionProjection(
        deviceSessionId: session.metadata.id,
        revision: revision,
        projectedAtEpochMs: _epochMs(now),
        expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
        phase: WatchSessionPhase.noActiveSession,
        phoneReachable: true,
        seriesIndex: session.currentSetIndex + 1,
        seriesTotal: 0,
        exerciseName: '',
        primaryAction: WatchPrimaryAction.none,
        statusLabel: 'Séance indisponible',
      );
    }

    final activeRest = await _findActiveRest(session.metadata.id);
    final setTimer = await sessionRepository.findSetTimerState(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    final scoreStopwatch = await sessionRepository.findScoreStopwatchState(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    final manualScore = await sessionRepository.findManualScoreState(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    final stepView = await _readStepViewIfStarted(session, snapshot);
    final stepState = stepView?.state;
    final currentStep = stepView?.currentStep ?? _initialStep(snapshot);
    final manualScoreProjection = _watchManualScoreProjection(
      snapshot: snapshot,
      currentStep: currentStep,
      stepState: stepState,
      stepResults: stepView?.results ?? const [],
      manualScore: manualScore,
    );
    final expectedPassages = _expectedPassages(snapshot);
    final projectedAtEpochMs = _epochMs(now);

    final scoreStopwatchTimer = scoreStopwatch == null
        ? null
        : _scoreStopwatchTimerProjection(scoreStopwatch, now);
    final setTimerProjection = setTimer == null
        ? null
        : _setTimerProjection(setTimer, now);
    final allTimers = <WatchTimerProjection>[
      if (activeRest != null) _restTimerProjection(activeRest, now),
      if (currentStep != null && currentStep.type == ExerciseStepType.time)
        _stepTimerProjection(stepState, currentStep, now),
      ?setTimerProjection,
      ?scoreStopwatchTimer,
    ];
    final displayTimers = allTimers
        .where((timer) => timer.kind != WatchTimerKind.setTimer)
        .toList(growable: false);
    final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first;
    final secondaryTimers = dominantTimer == null
        ? const <WatchTimerProjection>[]
        : displayTimers.skip(1).toList(growable: false);
    final phase = _phase(
      session: session,
      snapshot: snapshot,
      activeRest: activeRest,
      stepState: stepState,
      currentStep: currentStep,
      timers: allTimers,
    );
    final healthServicesExerciseTypeStrategy =
        _sessionHealthServicesExerciseTypeStrategy(
          session.resolvedTemplateSnapshotJson,
        );

    return WatchSessionProjection(
      deviceSessionId: session.metadata.id,
      revision: revision,
      projectedAtEpochMs: projectedAtEpochMs,
      expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
      phase: phase,
      phoneReachable: true,
      seriesIndex: session.currentSetIndex + 1,
      seriesTotal: snapshot.setsCount,
      exerciseName: snapshot.exerciseNameSnapshot,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
      passageIndex: expectedPassages > 1 && stepState != null
          ? stepState.currentPassageIndex + 1
          : null,
      passageTotal: expectedPassages > 1 ? expectedPassages : null,
      stepIndex: currentStep == null
          ? null
          : (stepState?.currentStepIndex ?? 0) + 1,
      stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length,
      stepName: currentStep?.name,
      stepType: switch (currentStep?.type) {
        ExerciseStepType.time => WatchStepType.time,
        ExerciseStepType.reps => WatchStepType.reps,
        null => null,
      },
      stepTargetValue: currentStep?.defaultTargetValue,
      dominantTimer: dominantTimer,
      secondaryTimers: secondaryTimers,
      primaryAction: _primaryAction(phase),
      secondaryActions: _secondaryActions(
        phase: phase,
        snapshot: snapshot,
        stepState: stepState,
        expectedPassages: expectedPassages,
      ),
      nextExerciseName: activeRest != null
          ? _restNextExerciseName(
              session.resolvedTemplateSnapshotJson,
              session,
              activeRest,
              snapshot,
            )
          : phase == WatchSessionPhase.betweenSetsReady
          ? _betweenSetsNextExerciseName(
              session.resolvedTemplateSnapshotJson,
              session,
              snapshot,
            )
          : null,
      statusLabel: _statusLabel(phase, dominantTimer),
      hasManualScore: manualScoreProjection != null,
      currentManualScoreValue: manualScoreProjection?.value,
      canDecrementScore: (manualScoreProjection?.value ?? 0) > 0,
      manualScoreTargetValue: manualScoreProjection?.targetValue,
      manualScoreTargetLabel: manualScoreProjection?.targetLabel,
      manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
      manualScoreScope: manualScoreProjection?.scope,
      healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy,
    );
  }

  Future<ActiveExerciseStepProgressView?> _readStepViewIfStarted(
    ActiveWorkoutSession session,
    _ResolvedExerciseSnapshot snapshot,
  ) async {
    if (snapshot.steps.isEmpty) {
      return null;
    }
    final state = await sessionRepository.findExerciseStepProgressState(
      sessionId: session.metadata.id,
      programIndex: session.currentProgramIndex,
      exerciseIndex: session.currentExerciseIndex,
      setIndex: session.currentSetIndex,
    );
    if (state == null) {
      return null;
    }
    if (state.status == ActiveExerciseStepProgressStatus.runningTimer) {
      return ActiveExerciseStepUseCases(
        sessionRepository: sessionRepository,
        clock: clock,
        ids: ids,
        originDeviceId: originDeviceId,
      ).readProgress(
        sessionId: session.metadata.id,
        programIndex: session.currentProgramIndex,
        exerciseIndex: session.currentExerciseIndex,
        setIndex: session.currentSetIndex,
      );
    }
    return ActiveExerciseStepProgressView(
      state: state,
      steps: snapshot.steps,
      expectedPassages: _expectedPassages(snapshot),
      results: await _stepResultsForCurrentSet(session),
    );
  }

  Future<List<ActiveExerciseStepResult>> _stepResultsForCurrentSet(
    ActiveWorkoutSession session,
  ) async {
    return (await sessionRepository.listExerciseStepResults(
          session.metadata.id,
        ))
        .where(
          (result) =>
              result.programIndex == session.currentProgramIndex &&
              result.exerciseIndex == session.currentExerciseIndex &&
              result.setIndex == session.currentSetIndex,
        )
        .toList();
  }

  Future<ActiveRestState?> _findActiveRest(String sessionId) async {
    final active =
        (await sessionRepository.listRestStates(sessionId))
            .where((rest) => rest.endedAt == null && rest.skippedAt == null)
            .toList()
          ..sort((left, right) => right.startedAt.compareTo(left.startedAt));
    return active.isEmpty ? null : active.first;
  }
}

final class _WatchManualScoreProjectionData {
  const _WatchManualScoreProjectionData({
    required this.scope,
    required this.value,
    this.targetValue,
    this.targetLabel,
    this.repsTargetValue,
  });

  final WatchManualScoreScope scope;
  final double value;
  final double? targetValue;
  final String? targetLabel;
  final int? repsTargetValue;
}

_WatchManualScoreProjectionData? _watchManualScoreProjection({
  required _ResolvedExerciseSnapshot snapshot,
  required ExerciseStep? currentStep,
  required ActiveExerciseStepProgressState? stepState,
  required List<ActiveExerciseStepResult> stepResults,
  required ActiveManualScoreState? manualScore,
}) {
  final step = currentStep;
  if (step != null &&
      step.hasScore &&
      step.scoreInputMode == ScoreInputMode.manual &&
      !step.linkedToSeriesScore) {
    final result = _currentIndependentStepScoreResult(
      step: step,
      stepState: stepState,
      stepResults: stepResults,
    );
    return _WatchManualScoreProjectionData(
      scope: WatchManualScoreScope.step,
      value: result?.actualScore ?? 0,
      targetValue: step.defaultTargetScore,
      targetLabel: step.defaultTargetScore == null ? null : 'Cible',
      repsTargetValue: step.type == ExerciseStepType.reps
          ? step.defaultTargetValue
          : null,
    );
  }
  if (snapshot.scoreEnabled &&
      snapshot.scoreInputModeSnapshot == ScoreInputMode.manual) {
    return _WatchManualScoreProjectionData(
      scope: WatchManualScoreScope.series,
      value: manualScore?.value ?? 0,
      targetValue: snapshot.targetScore,
      targetLabel: snapshot.targetScore == null ? null : 'Cible',
    );
  }
  return null;
}

ActiveExerciseStepResult? _currentIndependentStepScoreResult({
  required ExerciseStep step,
  required ActiveExerciseStepProgressState? stepState,
  required List<ActiveExerciseStepResult> stepResults,
}) {
  final state = stepState;
  if (state == null) {
    return null;
  }
  final matches = stepResults.where(
    (result) =>
        result.passageIndex == state.currentPassageIndex &&
        result.stepIndex == state.currentStepIndex &&
        result.stepSnapshotId == step.id &&
        result.hasScoreSnapshot &&
        result.scoreInputModeSnapshot == ScoreInputMode.manual,
  );
  return matches.isEmpty ? null : matches.last;
}

WatchSessionPhase _phase({
  required ActiveWorkoutSession session,
  required _ResolvedExerciseSnapshot snapshot,
  required ActiveRestState? activeRest,
  required ActiveExerciseStepProgressState? stepState,
  required ExerciseStep? currentStep,
  required List<WatchTimerProjection> timers,
}) {
  if (activeRest != null) {
    return activeRest.pausedAt == null
        ? WatchSessionPhase.restRunning
        : WatchSessionPhase.restPaused;
  }
  if (session.status == ActiveWorkoutStatus.paused) {
    return WatchSessionPhase.paused;
  }
  if (_isNextTimerReady(snapshot, stepState, currentStep)) {
    return WatchSessionPhase.nextTimerReady;
  }
  if (timers.any((timer) => timer.runState == WatchTimerRunState.running)) {
    return WatchSessionPhase.running;
  }
  return session.currentProgramIndex == 0 &&
          session.currentExerciseIndex == 0 &&
          session.currentSetIndex == 0
      ? WatchSessionPhase.ready
      : WatchSessionPhase.betweenSetsReady;
}

WatchPrimaryAction _primaryAction(WatchSessionPhase phase) {
  return switch (phase) {
    WatchSessionPhase.noActiveSession => WatchPrimaryAction.none,
    WatchSessionPhase.ready => WatchPrimaryAction.startCurrentExercise,
    WatchSessionPhase.running => WatchPrimaryAction.pauseSession,
    WatchSessionPhase.paused => WatchPrimaryAction.resumeSession,
    WatchSessionPhase.nextTimerReady =>
      WatchPrimaryAction.startPreparedTimedStep,
    WatchSessionPhase.restRunning => WatchPrimaryAction.pauseSession,
    WatchSessionPhase.restPaused => WatchPrimaryAction.resumeSession,
    WatchSessionPhase.betweenSetsReady =>
      WatchPrimaryAction.startCurrentExercise,
  };
}

List<WatchSecondaryAction> _secondaryActions({
  required WatchSessionPhase phase,
  required _ResolvedExerciseSnapshot snapshot,
  required ActiveExerciseStepProgressState? stepState,
  required int expectedPassages,
}) {
  if (phase == WatchSessionPhase.noActiveSession) {
    return const [];
  }
  if (phase == WatchSessionPhase.restRunning ||
      phase == WatchSessionPhase.restPaused) {
    return const [WatchSecondaryAction.skipCurrentRest];
  }
  final hasCurrentStep =
      snapshot.steps.isNotEmpty &&
      stepState?.status != ActiveExerciseStepProgressStatus.sequenceComplete;
  final hasPassageToSkip =
      hasCurrentStep &&
      expectedPassages > 1 &&
      stepState != null &&
      stepState.currentPassageIndex < expectedPassages - 1;
  return [
    if (hasCurrentStep) WatchSecondaryAction.skipCurrentStep,
    if (hasPassageToSkip) WatchSecondaryAction.skipCurrentPassage,
    WatchSecondaryAction.finishCurrentSet,
    WatchSecondaryAction.skipCurrentSet,
  ];
}

String? _statusLabel(
  WatchSessionPhase phase,
  WatchTimerProjection? dominantTimer,
) {
  return switch (phase) {
    WatchSessionPhase.noActiveSession => 'Aucune séance en cours',
    WatchSessionPhase.ready => 'Prêt à démarrer',
    WatchSessionPhase.running => dominantTimer?.label ?? 'En cours',
    WatchSessionPhase.paused => 'Séance en pause',
    WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt',
    WatchSessionPhase.restRunning => 'Repos en cours',
    WatchSessionPhase.restPaused => 'Repos en pause',
    WatchSessionPhase.betweenSetsReady => null,
  };
}

WatchTimerProjection _restTimerProjection(ActiveRestState rest, DateTime now) {
  final paused = rest.pausedAt != null;
  return WatchTimerProjection(
    kind: WatchTimerKind.rest,
    label: 'Repos',
    displayMode: WatchTimerDisplayMode.countdown,
    runState: paused ? WatchTimerRunState.paused : WatchTimerRunState.running,
    referenceEpochMs: _epochMs(now),
    accumulatedMs: rest.elapsedMillisecondsAt(now),
    startedAtEpochMs: paused ? null : _epochMs(now),
    targetMs: rest.adjustedRestSeconds * 1000,
  );
}

WatchTimerProjection _stepTimerProjection(
  ActiveExerciseStepProgressState? state,
  ExerciseStep step,
  DateTime now,
) {
  return WatchTimerProjection(
    kind: WatchTimerKind.step,
    label: 'Chrono étape',
    displayMode: WatchTimerDisplayMode.countdown,
    runState: switch (state?.status) {
      ActiveExerciseStepProgressStatus.runningTimer =>
        WatchTimerRunState.running,
      ActiveExerciseStepProgressStatus.pausedTimer => WatchTimerRunState.paused,
      _ => WatchTimerRunState.stopped,
    },
    referenceEpochMs: _epochMs(now),
    accumulatedMs: state?.elapsedMillisecondsAt(now) ?? 0,
    startedAtEpochMs:
        state?.status == ActiveExerciseStepProgressStatus.runningTimer
        ? _epochMs(now)
        : null,
    targetMs: step.defaultTargetValue * 1000,
  );
}

WatchTimerProjection? _scoreStopwatchTimerProjection(
  ActiveScoreStopwatchState state,
  DateTime now,
) {
  if (state.status == ActiveScoreStopwatchStatus.stopped) {
    return null;
  }
  return WatchTimerProjection(
    kind: WatchTimerKind.scoreStopwatch,
    label: 'Score chrono',
    displayMode: WatchTimerDisplayMode.elapsed,
    runState: state.status == ActiveScoreStopwatchStatus.running
        ? WatchTimerRunState.running
        : WatchTimerRunState.paused,
    referenceEpochMs: _epochMs(now),
    accumulatedMs: state.accumulatedMs,
    startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running
        ? _epochMs(state.startedAt)
        : null,
  );
}

WatchTimerProjection? _setTimerProjection(
  ActiveSetTimerState state,
  DateTime now,
) {
  if (state.status == ActiveSetTimerStatus.stopped ||
      state.status == ActiveSetTimerStatus.skipped) {
    return null;
  }
  return WatchTimerProjection(
    kind: WatchTimerKind.setTimer,
    label: 'Temps de série',
    displayMode: WatchTimerDisplayMode.elapsed,
    runState: state.status == ActiveSetTimerStatus.running
        ? WatchTimerRunState.running
        : WatchTimerRunState.paused,
    referenceEpochMs: _epochMs(now),
    accumulatedMs: state.accumulatedMs,
    startedAtEpochMs:
        state.status == ActiveSetTimerStatus.running && state.startedAt != null
        ? _epochMs(state.startedAt!)
        : null,
  );
}

ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) {
  return snapshot.steps.isEmpty ? null : snapshot.steps.first;
}

bool _isNextTimerReady(
  _ResolvedExerciseSnapshot snapshot,
  ActiveExerciseStepProgressState? state,
  ExerciseStep? currentStep,
) {
  if (state == null ||
      currentStep == null ||
      state.status != ActiveExerciseStepProgressStatus.stoppedTimer ||
      currentStep.type != ExerciseStepType.time) {
    return false;
  }
  final previous = _previousStep(snapshot, state);
  return previous?.type == ExerciseStepType.time;
}

ExerciseStep? _previousStep(
  _ResolvedExerciseSnapshot snapshot,
  ActiveExerciseStepProgressState state,
) {
  if (snapshot.steps.isEmpty) {
    return null;
  }
  if (state.currentStepIndex > 0) {
    return snapshot.steps[state.currentStepIndex - 1];
  }
  if (state.currentPassageIndex > 0) {
    return snapshot.steps.last;
  }
  return null;
}

int _expectedPassages(_ResolvedExerciseSnapshot snapshot) {
  return snapshot.repsEnabled
      ? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
      : 1;
}

String? _restNextExerciseName(
  String resolvedTemplateSnapshotJson,
  ActiveWorkoutSession session,
  ActiveRestState rest,
  _ResolvedExerciseSnapshot currentSnapshot,
) {
  final sessionIsAfterRestSource =
      _comparePositions(
        session.currentProgramIndex,
        session.currentExerciseIndex,
        session.currentSetIndex,
        rest.afterProgramIndex,
        rest.afterExerciseIndex,
        rest.afterSetIndex,
      ) >
      0;
  if (sessionIsAfterRestSource) {
    return currentSnapshot.exerciseNameSnapshot;
  }
  final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson);
  final setSnapshots = _listSetSnapshots(resolvedTemplateSnapshotJson);
  final currentIndex = setSnapshots.indexWhere(
    (snapshot) =>
        snapshot.programIndex == rest.afterProgramIndex &&
        snapshot.exerciseIndex == rest.afterExerciseIndex &&
        snapshot.setIndex == rest.afterSetIndex,
  );
  if (currentIndex == -1 || currentIndex + 1 >= setSnapshots.length) {
    return null;
  }
  final next = setSnapshots[currentIndex + 1];
  return snapshots[next.exerciseSnapshotId]?.exerciseNameSnapshot;
}

String? _betweenSetsNextExerciseName(
  String resolvedTemplateSnapshotJson,
  ActiveWorkoutSession session,
  _ResolvedExerciseSnapshot currentSnapshot,
) {
  final setSnapshots = _listSetSnapshots(resolvedTemplateSnapshotJson);
  final currentIndex = setSnapshots.indexWhere(
    (snapshot) =>
        snapshot.programIndex == session.currentProgramIndex &&
        snapshot.exerciseIndex == session.currentExerciseIndex &&
        snapshot.setIndex == session.currentSetIndex,
  );
  if (currentIndex <= 0) {
    return null;
  }
  final previous = setSnapshots[currentIndex - 1];
  final current = setSnapshots[currentIndex];
  if (previous.exerciseSnapshotId == current.exerciseSnapshotId) {
    return null;
  }
  return currentSnapshot.exerciseNameSnapshot;
}

int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch;

int _watchProjectionExpiresAtEpochMs(DateTime projectedAt) {
  return projectedAt
      .toUtc()
      .add(const Duration(seconds: 12))
      .millisecondsSinceEpoch;
}

final class ActiveExerciseStepUseCases {
  const ActiveExerciseStepUseCases({
    required this.sessionRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
    this.activeSessionUseCases,
  });

  final ActiveSessionRepository sessionRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;
  final ActiveWorkoutSessionUseCases? activeSessionUseCases;

  Future<ActiveExerciseStepProgressView> startOrResumeProgress({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final now = clock.now();
    final existing = await sessionRepository.findExerciseStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final state =
        existing ??
        _initialStepProgressState(
          sessionId: sessionId,
          context: context,
          now: now,
        );
    if (existing == null) {
      await sessionRepository.saveExerciseStepProgressState(state);
    }
    final advanced = await _autoAdvanceElapsedTimers(context, state, now);
    return ActiveExerciseStepProgressView(
      state: advanced,
      steps: context.steps,
      expectedPassages: context.expectedPassages,
      results: await _stepResultsForPosition(context),
    );
  }

  Future<ActiveExerciseStepProgressView> readProgress({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) => startOrResumeProgress(
    sessionId: sessionId,
    programIndex: programIndex,
    exerciseIndex: exerciseIndex,
    setIndex: setIndex,
  );

  Future<ActiveExerciseStepProgressState> startTimer({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final view = await startOrResumeProgress(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final step = view.currentStep;
    if (step == null || step.type != ExerciseStepType.time) {
      throw const DomainException('Current exercise step is not timed.');
    }
    if (view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
      return view.state;
    }
    final now = clock.now();
    final running = view.state.copyWith(
      metadata: view.state.metadata.touch(now),
      status: ActiveExerciseStepProgressStatus.runningTimer,
      startedAt: now,
      lastTransitionAt: now,
    );
    await sessionRepository.saveExerciseStepProgressState(running);
    return running;
  }

  Future<ActiveExerciseStepProgressState> pauseTimer({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final state = await _requiredStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (state.status != ActiveExerciseStepProgressStatus.runningTimer) {
      throw const DomainException('Exercise step timer is not running.');
    }
    final now = clock.now();
    final paused = state.copyWith(
      metadata: state.metadata.touch(now),
      status: ActiveExerciseStepProgressStatus.pausedTimer,
      startedAt: null,
      accumulatedMs: state.elapsedMillisecondsAt(now),
      lastTransitionAt: now,
    );
    await sessionRepository.saveExerciseStepProgressState(paused);
    return paused;
  }

  Future<ActiveExerciseStepProgressState> resumeTimer({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return startTimer(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
  }

  int elapsedMilliseconds(ActiveExerciseStepProgressState state) {
    return state.elapsedMillisecondsAt(clock.now());
  }

  int remainingMilliseconds({
    required ActiveExerciseStepProgressState state,
    required ExerciseStep step,
  }) {
    final targetMs = step.defaultTargetValue * 1000;
    final remaining = targetMs - elapsedMilliseconds(state);
    return remaining < 0 ? 0 : remaining;
  }

  Future<ManualScoreUpdateResult> incrementCurrentStepScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return _updateCurrentLinkedStepScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      increment: true,
    );
  }

  Future<ManualScoreUpdateResult> decrementCurrentStepScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return _updateCurrentLinkedStepScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      increment: false,
    );
  }

  Future<bool> incrementCurrentIndependentStepScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return _updateCurrentIndependentStepScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      increment: true,
    );
  }

  Future<bool> decrementCurrentIndependentStepScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) {
    return _updateCurrentIndependentStepScore(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      increment: false,
    );
  }

  Future<ActiveExerciseStepResult> completeCurrentStep({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    int? actualReps,
    double? actualScore,
    int? actualScoreTimeMs,
    String? note,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final now = clock.now();
    final currentState = await _requiredStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final currentStep = _currentStep(context, currentState);
    if (currentState.status == ActiveExerciseStepProgressStatus.runningTimer &&
        currentStep.type == ExerciseStepType.time) {
      final targetMs = currentStep.defaultTargetValue * 1000;
      final elapsedMs = currentState.elapsedMillisecondsAt(now);
      if (elapsedMs >= targetMs) {
        final existing = await _findStepResultForState(
          context,
          currentState,
          currentStep,
        );
        final completed = _stepResult(
          context: context,
          state: currentState,
          step: currentStep,
          existingResult: existing,
          status: SetResultStatus.completed,
          now: now,
          actualTimeMs: targetMs,
          actualScore: actualScore ?? existing?.actualScore,
          actualScoreTimeMs: actualScoreTimeMs,
          note: note,
        );
        await sessionRepository.saveExerciseStepResult(completed);
        await _syncLinkedSeriesScore(context, currentStep, actualScore);
        await sessionRepository.saveExerciseStepProgressState(
          _advanceState(
            context,
            currentState,
            now,
            runningTimerOverflowMs: elapsedMs - targetMs,
            completedStep: currentStep,
          ),
        );
        return completed;
      }
    }
    final state = await _autoAdvanceElapsedTimers(context, currentState, now);
    final step = _currentStep(context, state);
    if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
      throw const DomainException('Exercise step sequence is complete.');
    }
    final existing = await _findStepResultForState(context, state, step);
    final completed = _stepResult(
      context: context,
      state: state,
      step: step,
      existingResult: existing,
      status: SetResultStatus.completed,
      now: now,
      actualTimeMs: step.type == ExerciseStepType.time
          ? state.elapsedMillisecondsAt(now)
          : null,
      actualReps: step.type == ExerciseStepType.reps
          ? actualReps ?? step.defaultTargetValue
          : null,
      actualScore: actualScore ?? existing?.actualScore,
      actualScoreTimeMs: actualScoreTimeMs,
      note: note,
    );
    await sessionRepository.saveExerciseStepResult(completed);
    await _syncLinkedSeriesScore(context, step, actualScore);
    await sessionRepository.saveExerciseStepProgressState(
      _advanceState(context, state, now, completedStep: step),
    );
    return completed;
  }

  Future<ActiveExerciseStepResult> skipCurrentStep({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final state = await _requiredStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final now = clock.now();
    final step = _currentStep(context, state);
    final existing = await _findStepResultForState(context, state, step);
    final skipped = _stepResult(
      context: context,
      state: state,
      step: step,
      existingResult: existing,
      status: SetResultStatus.skipped,
      now: now,
    );
    await sessionRepository.saveExerciseStepResult(skipped);
    await sessionRepository.saveExerciseStepProgressState(
      _advanceState(context, state, now, completedStep: step),
    );
    return skipped;
  }

  Future<List<ActiveExerciseStepResult>> skipCurrentPassage({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    var state = await _requiredStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final now = clock.now();
    final skipped = <ActiveExerciseStepResult>[];
    final passage = state.currentPassageIndex;
    while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
        state.currentPassageIndex == passage) {
      final step = _currentStep(context, state);
      final result = _stepResult(
        context: context,
        state: state,
        step: step,
        status: SetResultStatus.skipped,
        now: now,
      );
      skipped.add(result);
      await sessionRepository.saveExerciseStepResult(result);
      state = _advanceState(context, state, now, completedStep: step);
    }
    await sessionRepository.saveExerciseStepProgressState(state);
    return skipped;
  }

  Future<List<ActiveExerciseStepResult>> skipSequence({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    var state = await _requiredStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final now = clock.now();
    final skipped = <ActiveExerciseStepResult>[];
    while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete) {
      final step = _currentStep(context, state);
      final result = _stepResult(
        context: context,
        state: state,
        step: step,
        status: SetResultStatus.skipped,
        now: now,
      );
      skipped.add(result);
      await sessionRepository.saveExerciseStepResult(result);
      state = _advanceState(context, state, now, completedStep: step);
    }
    await sessionRepository.saveExerciseStepProgressState(state);
    return skipped;
  }

  Future<ActiveExerciseStepProgressState> _requiredStepProgressState({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final state = await sessionRepository.findExerciseStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (state == null) {
      throw const DomainException('Exercise step progress state not found.');
    }
    return state;
  }

  Future<_StepSequenceContext> _stepContext({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    final session = await sessionRepository.findById(sessionId);
    if (session == null) {
      throw const DomainException('Active workout session not found.');
    }
    final snapshot = _findExerciseSnapshot(
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
    );
    if (snapshot == null || snapshot.steps.isEmpty) {
      throw const DomainException('Exercise step sequence not found.');
    }
    return _StepSequenceContext(
      sessionId: sessionId,
      programSnapshotId: snapshot.programSnapshotId,
      exerciseSnapshotId: snapshot.exerciseSnapshotId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      steps: snapshot.steps,
      scoreEnabled: snapshot.scoreEnabled,
      scoreInputMode: snapshot.scoreInputModeSnapshot,
      expectedPassages: snapshot.repsEnabled
          ? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
          : 1,
      autoStartNextTimedStep: snapshot.autoStartNextTimedStepEffective,
    );
  }

  ActiveExerciseStepProgressState _initialStepProgressState({
    required String sessionId,
    required _StepSequenceContext context,
    required DateTime now,
  }) {
    final firstStep = context.steps.first;
    return ActiveExerciseStepProgressState(
      metadata: _newMetadata(ids, originDeviceId, now),
      activeWorkoutSessionId: sessionId,
      programIndex: context.programIndex,
      exerciseIndex: context.exerciseIndex,
      setIndex: context.setIndex,
      currentPassageIndex: 0,
      currentStepIndex: 0,
      currentStepSnapshotId: firstStep.id,
      status: firstStep.type == ExerciseStepType.time
          ? ActiveExerciseStepProgressStatus.stoppedTimer
          : ActiveExerciseStepProgressStatus.waitingManual,
      accumulatedMs: 0,
      lastTransitionAt: now,
    );
  }

  Future<ActiveExerciseStepProgressState> _autoAdvanceElapsedTimers(
    _StepSequenceContext context,
    ActiveExerciseStepProgressState state,
    DateTime now,
  ) async {
    var current = state;
    while (current.status == ActiveExerciseStepProgressStatus.runningTimer) {
      final step = _currentStep(context, current);
      if (step.type != ExerciseStepType.time) {
        break;
      }
      final targetMs = step.defaultTargetValue * 1000;
      final elapsed = current.elapsedMillisecondsAt(now);
      if (elapsed < targetMs) {
        break;
      }
      final result = _stepResult(
        context: context,
        state: current,
        step: step,
        status: SetResultStatus.completed,
        now: now,
        actualTimeMs: targetMs,
      );
      await sessionRepository.saveExerciseStepResult(result);
      await _syncLinkedSeriesScore(context, step, result.actualScore);
      final overflowMs = elapsed - targetMs;
      current = _advanceState(
        context,
        current,
        now,
        runningTimerOverflowMs: overflowMs,
        completedStep: step,
      );
      await sessionRepository.saveExerciseStepProgressState(current);
    }
    return current;
  }

  ActiveExerciseStepProgressState _advanceState(
    _StepSequenceContext context,
    ActiveExerciseStepProgressState state,
    DateTime now, {
    int runningTimerOverflowMs = 0,
    ExerciseStep? completedStep,
  }) {
    var nextPassage = state.currentPassageIndex;
    var nextStep = state.currentStepIndex + 1;
    if (nextStep >= context.steps.length) {
      nextStep = 0;
      nextPassage++;
    }
    if (nextPassage >= context.expectedPassages) {
      return state.copyWith(
        metadata: state.metadata.touch(now),
        status: ActiveExerciseStepProgressStatus.sequenceComplete,
        startedAt: null,
        accumulatedMs: 0,
        lastTransitionAt: now,
      );
    }
    final step = context.steps[nextStep];
    final shouldStopBeforeNextTimedStep =
        !context.autoStartNextTimedStep &&
        completedStep?.type == ExerciseStepType.time &&
        step.type == ExerciseStepType.time;
    final resolvedOverflowMs = shouldStopBeforeNextTimedStep
        ? 0
        : runningTimerOverflowMs;
    final status = step.type == ExerciseStepType.time
        ? resolvedOverflowMs > 0
              ? ActiveExerciseStepProgressStatus.runningTimer
              : ActiveExerciseStepProgressStatus.stoppedTimer
        : ActiveExerciseStepProgressStatus.waitingManual;
    return state.copyWith(
      metadata: state.metadata.touch(now),
      currentPassageIndex: nextPassage,
      currentStepIndex: nextStep,
      currentStepSnapshotId: step.id,
      status: status,
      startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
          ? now.subtract(Duration(milliseconds: resolvedOverflowMs))
          : null,
      accumulatedMs: 0,
      lastTransitionAt: now,
    );
  }

  ExerciseStep _currentStep(
    _StepSequenceContext context,
    ActiveExerciseStepProgressState state,
  ) {
    if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
      throw const DomainException('Exercise step sequence is complete.');
    }
    return context.steps[state.currentStepIndex];
  }

  Future<List<ActiveExerciseStepResult>> _stepResultsForPosition(
    _StepSequenceContext context,
  ) async {
    return (await sessionRepository.listExerciseStepResults(context.sessionId))
        .where(
          (result) =>
              result.programIndex == context.programIndex &&
              result.exerciseIndex == context.exerciseIndex &&
              result.setIndex == context.setIndex,
        )
        .toList();
  }

  Future<ActiveExerciseStepResult?> _findStepResultForState(
    _StepSequenceContext context,
    ActiveExerciseStepProgressState state,
    ExerciseStep step,
  ) async {
    final matches = (await _stepResultsForPosition(context)).where(
      (result) =>
          result.passageIndex == state.currentPassageIndex &&
          result.stepIndex == state.currentStepIndex &&
          result.stepSnapshotId == step.id,
    );
    return matches.isEmpty ? null : matches.last;
  }

  ActiveExerciseStepResult _stepResult({
    required _StepSequenceContext context,
    required ActiveExerciseStepProgressState state,
    required ExerciseStep step,
    ActiveExerciseStepResult? existingResult,
    required SetResultStatus status,
    required DateTime now,
    int? actualTimeMs,
    int? actualReps,
    double? actualScore,
    int? actualScoreTimeMs,
    String? note,
  }) {
    return ActiveExerciseStepResult(
      metadata:
          existingResult?.metadata.touch(now) ??
          _newMetadata(ids, originDeviceId, now),
      activeWorkoutSessionId: context.sessionId,
      programSnapshotId: context.programSnapshotId,
      exerciseSnapshotId: context.exerciseSnapshotId,
      programIndex: context.programIndex,
      exerciseIndex: context.exerciseIndex,
      setIndex: context.setIndex,
      passageIndex: state.currentPassageIndex,
      stepIndex: state.currentStepIndex,
      stepSnapshotId: step.id,
      stepNameSnapshot: step.name,
      stepTypeSnapshot: step.type,
      targetValueSnapshot: step.defaultTargetValue,
      hasScoreSnapshot: step.hasScore,
      scoreInputModeSnapshot: step.hasScore ? step.scoreInputMode : null,
      scoreLabelSnapshot: step.scoreLabel,
      scoreUnitSnapshot: step.scoreUnit,
      targetScoreSnapshot: step.defaultTargetScore,
      targetScoreTimeMsSnapshot: step.defaultTargetScoreTimeMs,
      status: status,
      startedAt: state.startedAt,
      completedAt: status == SetResultStatus.completed ? now : null,
      actualTimeMs: status == SetResultStatus.completed ? actualTimeMs : null,
      actualReps: status == SetResultStatus.completed ? actualReps : null,
      actualScore: status == SetResultStatus.completed ? actualScore : null,
      actualScoreTimeMs: status == SetResultStatus.completed
          ? actualScoreTimeMs
          : null,
      note: note,
    );
  }

  Future<void> _syncLinkedSeriesScore(
    _StepSequenceContext context,
    ExerciseStep step,
    double? actualScore,
  ) async {
    if (!step.linkedToSeriesScore || actualScore == null || actualScore == 0) {
      return;
    }
    _ensureLinkedSeriesScore(context, step);
    final useCases = _requiredActiveSessionUseCases();
    final current = await useCases.findManualScore(
      sessionId: context.sessionId,
      programIndex: context.programIndex,
      exerciseIndex: context.exerciseIndex,
      setIndex: context.setIndex,
    );
    await useCases.setManualScore(
      sessionId: context.sessionId,
      programIndex: context.programIndex,
      exerciseIndex: context.exerciseIndex,
      setIndex: context.setIndex,
      value: (current?.value ?? 0) + actualScore,
    );
  }

  Future<bool> _updateCurrentIndependentStepScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required bool increment,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final view = await startOrResumeProgress(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final state = view.state;
    if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
      throw const DomainException('Exercise step sequence is complete.');
    }
    final step = _currentStep(context, state);
    if (!step.hasScore ||
        step.scoreInputMode != ScoreInputMode.manual ||
        step.linkedToSeriesScore) {
      throw const DomainException(
        'Current exercise step does not have independent manual score.',
      );
    }
    final existing = await _findStepResultForState(context, state, step);
    final current = existing?.actualScore ?? 0;
    final next = (current + (increment ? 1 : -1)).clamp(0, double.infinity);
    if (next == current) {
      return false;
    }
    final now = clock.now();
    final updated = _stepResult(
      context: context,
      state: state,
      step: step,
      existingResult: existing,
      status: SetResultStatus.completed,
      now: now,
      actualScore: next.toDouble(),
      actualTimeMs: existing?.actualTimeMs,
      actualReps: existing?.actualReps,
      actualScoreTimeMs: existing?.actualScoreTimeMs,
      note: existing?.note,
    );
    await sessionRepository.saveExerciseStepResult(updated);
    return true;
  }

  Future<ManualScoreUpdateResult> _updateCurrentLinkedStepScore({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required bool increment,
  }) async {
    final context = await _stepContext(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final state = await _requiredStepProgressState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    final step = _currentStep(context, state);
    _ensureLinkedSeriesScore(context, step);
    final useCases = _requiredActiveSessionUseCases();
    return increment
        ? useCases.incrementManualScore(
            sessionId: context.sessionId,
            programIndex: context.programIndex,
            exerciseIndex: context.exerciseIndex,
            setIndex: context.setIndex,
          )
        : useCases.decrementManualScore(
            sessionId: context.sessionId,
            programIndex: context.programIndex,
            exerciseIndex: context.exerciseIndex,
            setIndex: context.setIndex,
          );
  }

  void _ensureLinkedSeriesScore(
    _StepSequenceContext context,
    ExerciseStep step,
  ) {
    if (!step.linkedToSeriesScore) {
      throw const DomainException(
        'Current exercise step score is not linked to series score.',
      );
    }
    if (!context.scoreEnabled ||
        context.scoreInputMode != ScoreInputMode.manual) {
      throw const DomainException(
        'Linked series score requires manual series score.',
      );
    }
  }

  ActiveWorkoutSessionUseCases _requiredActiveSessionUseCases() {
    final useCases = activeSessionUseCases;
    if (useCases == null) {
      throw const DomainException(
        'Linked series score cannot be updated without session score use cases.',
      );
    }
    return useCases;
  }
}

final class CloseWorkoutSessionUseCase {
  const CloseWorkoutSessionUseCase({
    required this.sessionRepository,
    required this.historyRepository,
    this.telemetryRepository,
    required this.clock,
    required this.ids,
    required this.originDeviceId,
  });

  final ActiveSessionRepository sessionRepository;
  final WorkoutHistoryRepository historyRepository;
  final WorkoutTelemetryRepository? telemetryRepository;
  final Clock clock;
  final IdGenerator ids;
  final String originDeviceId;

  Future<WorkoutHistory> close({
    required String sessionId,
    required String nameSnapshot,
    required bool completed,
  }) async {
    final session = await sessionRepository.findById(sessionId);
    if (session == null) {
      throw const DomainException('Active workout session not found.');
    }
    final now = clock.now();
    final results = await sessionRepository.listSetResults(sessionId);
    final stepResults = await sessionRepository.listExerciseStepResults(
      sessionId,
    );
    final snapshots = _exerciseSnapshotsById(
      session.resolvedTemplateSnapshotJson,
    );
    final historyId = ids.newId();
    final historyResults = _historyResultsFromActiveResults(
      historyId: historyId,
      results: results,
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      now: now,
      ids: ids,
      originDeviceId: originDeviceId,
    );
    final historyStepResults = _historyStepResultsFromActiveResults(
      historyId: historyId,
      results: stepResults,
      resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
      now: now,
      ids: ids,
      originDeviceId: originDeviceId,
    );
    final telemetryAggregate = await telemetryRepository?.findAggregate(
      sessionId: session.metadata.id,
      scope: WorkoutTelemetryAggregateScope.session,
    );
    final telemetrySamples =
        await telemetryRepository?.listSamples(session.metadata.id) ??
        const <WorkoutTelemetrySample>[];
    final history = WorkoutHistory(
      metadata: EntityMetadata(
        id: historyId,
        createdAt: now,
        updatedAt: now,
        originDeviceId: originDeviceId,
      ),
      sourceWorkoutTemplateId: session.sourceWorkoutTemplateId,
      sourceActiveWorkoutSessionId: session.metadata.id,
      nameSnapshot: nameSnapshot,
      startedAt: session.startedAt,
      endedAt: now,
      totalActiveMs: session.elapsedActiveMillisecondsAt(now),
      completed: completed,
      historySnapshotJson: jsonEncode({
        'sessionId': session.metadata.id,
        'resolvedTemplateSnapshotJson': session.resolvedTemplateSnapshotJson,
        'results': results
            .map(
              (result) => {
                'programSnapshotId': result.programSnapshotId,
                'exerciseSnapshotId': result.exerciseSnapshotId,
                'programIndex': result.programIndex,
                'exerciseIndex': result.exerciseIndex,
                'setIndex': result.setIndex,
                'actualTimeMs': result.actualTimeMs,
                'actualReps': result.actualReps,
                'actualScore': result.actualScore,
                'actualScoreTimeMs': result.actualScoreTimeMs,
                'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
                'scoreLabelSnapshot': result.scoreLabelSnapshot,
                'scoreUnitSnapshot': result.scoreUnitSnapshot,
                'sourceExerciseIdSnapshot':
                    snapshots[result.exerciseSnapshotId]?.sourceExerciseId,
                'status': result.status.name,
              },
            )
            .toList(),
        'stepResults': stepResults
            .map(
              (result) => {
                'programSnapshotId': result.programSnapshotId,
                'exerciseSnapshotId': result.exerciseSnapshotId,
                'programIndex': result.programIndex,
                'exerciseIndex': result.exerciseIndex,
                'setIndex': result.setIndex,
                'passageIndex': result.passageIndex,
                'stepIndex': result.stepIndex,
                'stepSnapshotId': result.stepSnapshotId,
                'stepNameSnapshot': result.stepNameSnapshot,
                'stepTypeSnapshot': result.stepTypeSnapshot.name,
                'targetValueSnapshot': result.targetValueSnapshot,
                'hasScoreSnapshot': result.hasScoreSnapshot,
                'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
                'scoreLabelSnapshot': result.scoreLabelSnapshot,
                'scoreUnitSnapshot': result.scoreUnitSnapshot,
                'targetScoreSnapshot': result.targetScoreSnapshot,
                'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
                'status': result.status.name,
                'actualTimeMs': result.actualTimeMs,
                'actualReps': result.actualReps,
                'actualScore': result.actualScore,
                'actualScoreTimeMs': result.actualScoreTimeMs,
                'note': result.note,
                'sourceExerciseIdSnapshot':
                    snapshots[result.exerciseSnapshotId]?.sourceExerciseId,
              },
            )
            .toList(),
        'telemetrySamples': telemetrySamples
            .map(_workoutTelemetrySampleSnapshotJson)
            .toList(),
      }),
      minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
      averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
      maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm,
      totalDistanceMeters: telemetryAggregate?.totalDistanceMeters,
      totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal,
      results: historyResults,
      stepResults: historyStepResults,
    );
    await historyRepository.save(history);
    return history;
  }
}

final class WorkoutTelemetryUseCases {
  const WorkoutTelemetryUseCases({
    required this.repository,
    required this.clock,
    required this.ids,
  });

  static const persistedSampleInterval = Duration(seconds: 15);

  final WorkoutTelemetryRepository repository;
  final Clock clock;
  final IdGenerator ids;

  Future<List<WorkoutTelemetrySample>> listSamplesForHistory(
    WorkoutHistory history,
  ) async {
    final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
    if (sourceSessionId != null && sourceSessionId.isNotEmpty) {
      final samples = await repository.listSamples(sourceSessionId);
      if (samples.isNotEmpty) {
        return samples;
      }
    }
    return _telemetrySamplesFromHistorySnapshot(history);
  }

  Future<WorkoutTelemetryGraphSeries> readGraphSeriesForHistory({
    required WorkoutHistory history,
    required WorkoutTelemetryAggregateScope scope,
    int? programIndex,
    int? exerciseIndex,
    int? setIndex,
    int? passageIndex,
    int? stepIndex,
  }) async {
    final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
    var samples = const <WorkoutTelemetrySample>[];
    if (sourceSessionId != null && sourceSessionId.isNotEmpty) {
      samples = await repository.listSamplesForScope(
        sessionId: sourceSessionId,
        scope: scope,
        programIndex: programIndex,
        exerciseIndex: exerciseIndex,
        setIndex: setIndex,
        passageIndex: passageIndex,
        stepIndex: stepIndex,
      );
    }
    if (samples.isEmpty) {
      samples = _telemetrySamplesFromHistorySnapshot(history)
          .where(
            (sample) => _matchesTelemetryScope(
              sample,
              scope: scope,
              programIndex: programIndex,
              exerciseIndex: exerciseIndex,
              setIndex: setIndex,
              passageIndex: passageIndex,
              stepIndex: stepIndex,
            ),
          )
          .toList(growable: false);
    }
    return _telemetryGraphSeriesFromSamples(
      samples,
      scope: scope,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      passageIndex: passageIndex,
      stepIndex: stepIndex,
    );
  }

  Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
    WatchTelemetrySample sample,
  ) async {
    final domainSample = _telemetrySampleFromWatch(sample);
    if (domainSample == null) {
      return const [];
    }
    final inserted = await repository.saveSample(domainSample);
    if (!inserted) {
      return const [];
    }
    final samples = await repository.listSamples(domainSample.sessionId);
    final aggregates = _telemetryAggregatesFromSamples(samples);
    await repository.replaceAggregatesForSession(
      sessionId: domainSample.sessionId,
      aggregates: aggregates,
    );
    return aggregates;
  }

  WorkoutTelemetrySample? _telemetrySampleFromWatch(
    WatchTelemetrySample sample,
  ) {
    final sessionId = sample.sessionId.trim();
    final hasHeartRate =
        sample.heartRateBpm != null && sample.heartRateBpm! > 0;
    final hasDistance =
        sample.distanceMeters != null && sample.distanceMeters! >= 0;
    final hasCalories =
        sample.caloriesKcal != null && sample.caloriesKcal! >= 0;
    if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) {
      return null;
    }
    final capturedAt = sample.capturedAtEpochMs > 0
        ? DateTime.fromMillisecondsSinceEpoch(
            sample.capturedAtEpochMs,
            isUtc: true,
          )
        : clock.now();
    return WorkoutTelemetrySample(
      id: _telemetrySampleId(sample, sessionId, capturedAt),
      sessionId: sessionId,
      capturedAt: capturedAt,
      programIndex: sample.programIndex,
      exerciseIndex: sample.exerciseIndex,
      setIndex: sample.setIndex,
      passageIndex: sample.passageIndex,
      stepIndex: sample.stepIndex,
      programSnapshotId: sample.programSnapshotId,
      exerciseSnapshotId: sample.exerciseSnapshotId,
      stepSnapshotId: sample.stepSnapshotId,
      heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
      distanceMeters: hasDistance ? sample.distanceMeters : null,
      caloriesKcal: hasCalories ? sample.caloriesKcal : null,
    );
  }

  String _telemetrySampleId(
    WatchTelemetrySample sample,
    String sessionId,
    DateTime capturedAt,
  ) {
    final bucketMs =
        capturedAt.millisecondsSinceEpoch ~/
        persistedSampleInterval.inMilliseconds *
        persistedSampleInterval.inMilliseconds;
    return 'telemetry:$sessionId:$bucketMs';
  }
}

WorkoutTelemetryGraphSeries _telemetryGraphSeriesFromSamples(
  List<WorkoutTelemetrySample> samples, {
  required WorkoutTelemetryAggregateScope scope,
  int? programIndex,
  int? exerciseIndex,
  int? setIndex,
  int? passageIndex,
  int? stepIndex,
}) {
  final ordered = samples.toList()
    ..sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
  if (ordered.isEmpty) {
    return WorkoutTelemetryGraphSeries(
      scope: scope,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
      passageIndex: passageIndex,
      stepIndex: stepIndex,
      points: const [],
    );
  }

  final firstCapturedAt = ordered.first.capturedAt;
  DateTime? previousCapturedAt;
  double? distanceBaseline;
  double? caloriesBaseline;
  int? minHeartRateBpm;
  int? maxHeartRateBpm;
  final points = <WorkoutTelemetryGraphPoint>[];

  for (final sample in ordered) {
    final heartRate = sample.heartRateBpm;
    if (heartRate != null) {
      minHeartRateBpm = minHeartRateBpm == null
          ? heartRate
          : (heartRate < minHeartRateBpm ? heartRate : minHeartRateBpm);
      maxHeartRateBpm = maxHeartRateBpm == null
          ? heartRate
          : (heartRate > maxHeartRateBpm ? heartRate : maxHeartRateBpm);
    }

    final distance = sample.distanceMeters;
    if (distance != null) {
      distanceBaseline ??= distance;
    }
    final calories = sample.caloriesKcal;
    if (calories != null) {
      caloriesBaseline ??= calories;
    }

    points.add(
      WorkoutTelemetryGraphPoint(
        capturedAt: sample.capturedAt,
        elapsedMs: sample.capturedAt.difference(firstCapturedAt).inMilliseconds,
        heartRateBpm: heartRate,
        distanceMeters: distance == null
            ? null
            : (distance - distanceBaseline!)
                  .clamp(0, double.infinity)
                  .toDouble(),
        caloriesKcal: calories == null
            ? null
            : (calories - caloriesBaseline!)
                  .clamp(0, double.infinity)
                  .toDouble(),
        startsAfterGap:
            previousCapturedAt != null &&
            sample.capturedAt.difference(previousCapturedAt) >
                const Duration(minutes: 2),
      ),
    );
    previousCapturedAt = sample.capturedAt;
  }

  return WorkoutTelemetryGraphSeries(
    scope: scope,
    programIndex: programIndex,
    exerciseIndex: exerciseIndex,
    setIndex: setIndex,
    passageIndex: passageIndex,
    stepIndex: stepIndex,
    points: points,
    minHeartRateBpm: minHeartRateBpm,
    maxHeartRateBpm: maxHeartRateBpm,
  );
}

Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
  WorkoutTelemetrySample sample,
) {
  return {
    'id': sample.id,
    'sessionId': sample.sessionId,
    'capturedAt': sample.capturedAt.toUtc().toIso8601String(),
    'programIndex': sample.programIndex,
    'exerciseIndex': sample.exerciseIndex,
    'setIndex': sample.setIndex,
    'passageIndex': sample.passageIndex,
    'stepIndex': sample.stepIndex,
    'programSnapshotId': sample.programSnapshotId,
    'exerciseSnapshotId': sample.exerciseSnapshotId,
    'stepSnapshotId': sample.stepSnapshotId,
    'heartRateBpm': sample.heartRateBpm,
    'distanceMeters': sample.distanceMeters,
    'caloriesKcal': sample.caloriesKcal,
  };
}

List<WorkoutTelemetrySample> _telemetrySamplesFromHistorySnapshot(
  WorkoutHistory history,
) {
  final decoded = jsonDecode(history.historySnapshotJson);
  if (decoded is! Map) {
    return const [];
  }
  final rawSamples = decoded['telemetrySamples'];
  if (rawSamples is! List) {
    return const [];
  }
  final output = <WorkoutTelemetrySample>[];
  for (final rawSample in rawSamples) {
    if (rawSample is! Map) {
      continue;
    }
    final json = Map<String, Object?>.from(rawSample);
    final id = json['id'] as String?;
    final sessionId = json['sessionId'] as String?;
    final capturedAt = DateTime.tryParse(json['capturedAt'] as String? ?? '');
    if (id == null || sessionId == null || capturedAt == null) {
      continue;
    }
    output.add(
      WorkoutTelemetrySample(
        id: id,
        sessionId: sessionId,
        capturedAt: capturedAt,
        programIndex: json['programIndex'] as int?,
        exerciseIndex: json['exerciseIndex'] as int?,
        setIndex: json['setIndex'] as int?,
        passageIndex: json['passageIndex'] as int?,
        stepIndex: json['stepIndex'] as int?,
        programSnapshotId: json['programSnapshotId'] as String?,
        exerciseSnapshotId: json['exerciseSnapshotId'] as String?,
        stepSnapshotId: json['stepSnapshotId'] as String?,
        heartRateBpm: json['heartRateBpm'] as int?,
        distanceMeters: (json['distanceMeters'] as num?)?.toDouble(),
        caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(),
      ),
    );
  }
  return output;
}

bool _matchesTelemetryScope(
  WorkoutTelemetrySample sample, {
  required WorkoutTelemetryAggregateScope scope,
  int? programIndex,
  int? exerciseIndex,
  int? setIndex,
  int? passageIndex,
  int? stepIndex,
}) {
  return switch (scope) {
    WorkoutTelemetryAggregateScope.session => true,
    WorkoutTelemetryAggregateScope.exercise =>
      sample.programIndex == programIndex &&
          sample.exerciseIndex == exerciseIndex,
    WorkoutTelemetryAggregateScope.set =>
      sample.programIndex == programIndex &&
          sample.exerciseIndex == exerciseIndex &&
          sample.setIndex == setIndex,
    WorkoutTelemetryAggregateScope.step =>
      sample.programIndex == programIndex &&
          sample.exerciseIndex == exerciseIndex &&
          sample.setIndex == setIndex &&
          (passageIndex == null || sample.passageIndex == passageIndex) &&
          sample.stepIndex == stepIndex,
  };
}

List<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
  List<WorkoutTelemetrySample> samples,
) {
  final builders = <_TelemetryScopeKey, _TelemetryAggregateBuilder>{};
  for (final sample in samples) {
    for (final key in _telemetryScopeKeys(sample)) {
      builders
          .putIfAbsent(key, () => _TelemetryAggregateBuilder(key))
          .add(sample);
    }
  }
  return [for (final builder in builders.values) builder.build()];
}

List<_TelemetryScopeKey> _telemetryScopeKeys(WorkoutTelemetrySample sample) {
  final keys = [
    _TelemetryScopeKey(
      sessionId: sample.sessionId,
      scope: WorkoutTelemetryAggregateScope.session,
    ),
  ];
  if (sample.programIndex != null && sample.exerciseIndex != null) {
    keys.add(
      _TelemetryScopeKey(
        sessionId: sample.sessionId,
        scope: WorkoutTelemetryAggregateScope.exercise,
        programIndex: sample.programIndex,
        exerciseIndex: sample.exerciseIndex,
      ),
    );
  }
  if (sample.programIndex != null &&
      sample.exerciseIndex != null &&
      sample.setIndex != null) {
    keys.add(
      _TelemetryScopeKey(
        sessionId: sample.sessionId,
        scope: WorkoutTelemetryAggregateScope.set,
        programIndex: sample.programIndex,
        exerciseIndex: sample.exerciseIndex,
        setIndex: sample.setIndex,
      ),
    );
  }
  if (sample.programIndex != null &&
      sample.exerciseIndex != null &&
      sample.setIndex != null &&
      sample.stepIndex != null) {
    keys.add(
      _TelemetryScopeKey(
        sessionId: sample.sessionId,
        scope: WorkoutTelemetryAggregateScope.step,
        programIndex: sample.programIndex,
        exerciseIndex: sample.exerciseIndex,
        setIndex: sample.setIndex,
        passageIndex: sample.passageIndex,
        stepIndex: sample.stepIndex,
      ),
    );
  }
  return keys;
}

final class _TelemetryScopeKey {
  const _TelemetryScopeKey({
    required this.sessionId,
    required this.scope,
    this.programIndex,
    this.exerciseIndex,
    this.setIndex,
    this.passageIndex,
    this.stepIndex,
  });

  final String sessionId;
  final WorkoutTelemetryAggregateScope scope;
  final int? programIndex;
  final int? exerciseIndex;
  final int? setIndex;
  final int? passageIndex;
  final int? stepIndex;

  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
        other is _TelemetryScopeKey &&
            sessionId == other.sessionId &&
            scope == other.scope &&
            programIndex == other.programIndex &&
            exerciseIndex == other.exerciseIndex &&
            setIndex == other.setIndex &&
            passageIndex == other.passageIndex &&
            stepIndex == other.stepIndex;
  }

  @override
  int get hashCode => Object.hash(
    sessionId,
    scope,
    programIndex,
    exerciseIndex,
    setIndex,
    passageIndex,
    stepIndex,
  );
}

final class _TelemetryAggregateBuilder {
  _TelemetryAggregateBuilder(this.key);

  final _TelemetryScopeKey key;
  var sampleCount = 0;
  var heartRateCount = 0;
  var heartRateSum = 0.0;
  int? minHeartRateBpm;
  int? maxHeartRateBpm;
  double? maxDistanceMeters;
  double? maxCaloriesKcal;

  void add(WorkoutTelemetrySample sample) {
    sampleCount += 1;
    final heartRate = sample.heartRateBpm;
    if (heartRate != null) {
      heartRateCount += 1;
      heartRateSum += heartRate;
      minHeartRateBpm = minHeartRateBpm == null
          ? heartRate
          : (heartRate < minHeartRateBpm! ? heartRate : minHeartRateBpm);
      maxHeartRateBpm = maxHeartRateBpm == null
          ? heartRate
          : (heartRate > maxHeartRateBpm! ? heartRate : maxHeartRateBpm);
    }
    final distance = sample.distanceMeters;
    if (distance != null) {
      maxDistanceMeters = maxDistanceMeters == null
          ? distance
          : (distance > maxDistanceMeters! ? distance : maxDistanceMeters);
    }
    final calories = sample.caloriesKcal;
    if (calories != null) {
      maxCaloriesKcal = maxCaloriesKcal == null
          ? calories
          : (calories > maxCaloriesKcal! ? calories : maxCaloriesKcal);
    }
  }

  WorkoutTelemetryAggregate build() {
    return WorkoutTelemetryAggregate(
      sessionId: key.sessionId,
      scope: key.scope,
      programIndex: key.programIndex,
      exerciseIndex: key.exerciseIndex,
      setIndex: key.setIndex,
      passageIndex: key.passageIndex,
      stepIndex: key.stepIndex,
      sampleCount: sampleCount,
      minHeartRateBpm: minHeartRateBpm,
      averageHeartRateBpm: heartRateCount == 0
          ? null
          : heartRateSum / heartRateCount,
      maxHeartRateBpm: maxHeartRateBpm,
      totalDistanceMeters: maxDistanceMeters,
      totalCaloriesKcal: maxCaloriesKcal,
    );
  }
}

final class ActiveWorkoutSensorState {
  const ActiveWorkoutSensorState({
    required this.sessionId,
    required this.firstSampleAt,
    required this.latestSampleAt,
    this.latestHeartRateBpm,
    required this.sampleCount,
    this.minHeartRateBpm,
    this.averageHeartRateBpm,
    this.maxHeartRateBpm,
    this.latestDistanceMeters,
    this.latestCaloriesKcal,
    required this.estimatedCaloriesKcal,
  });

  final String sessionId;
  final DateTime firstSampleAt;
  final DateTime latestSampleAt;
  final int? latestHeartRateBpm;
  final int sampleCount;
  final int? minHeartRateBpm;
  final double? averageHeartRateBpm;
  final int? maxHeartRateBpm;
  final double? latestDistanceMeters;
  final double? latestCaloriesKcal;
  final double estimatedCaloriesKcal;
}

final class ActiveWorkoutSensorUseCases {
  ActiveWorkoutSensorUseCases({required this.clock});

  final Clock clock;
  final _states = <String, _ActiveWorkoutSensorAccumulator>{};
  final _updates = StreamController<ActiveWorkoutSensorState>.broadcast();

  Stream<ActiveWorkoutSensorState> get updates => _updates.stream;

  ActiveWorkoutSensorState? current(String sessionId) {
    return _states[sessionId]?.snapshot;
  }

  ActiveWorkoutSensorState? recordHeartRateSample(WatchSensorSample sample) {
    return recordTelemetrySample(sample);
  }

  ActiveWorkoutSensorState? recordTelemetrySample(WatchTelemetrySample sample) {
    final sessionId = sample.sessionId.trim();
    final hasHeartRate =
        sample.heartRateBpm != null && sample.heartRateBpm! > 0;
    final hasDistance =
        sample.distanceMeters != null && sample.distanceMeters! >= 0;
    final hasCalories =
        sample.caloriesKcal != null && sample.caloriesKcal! >= 0;
    if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) {
      return null;
    }
    final recordedAt = sample.capturedAtEpochMs > 0
        ? DateTime.fromMillisecondsSinceEpoch(
            sample.capturedAtEpochMs,
            isUtc: true,
          )
        : clock.now();
    final accumulator = _states.putIfAbsent(
      sessionId,
      () => _ActiveWorkoutSensorAccumulator(sessionId, recordedAt),
    );
    final snapshot = accumulator.add(
      sampleId: sample.sampleId,
      heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
      distanceMeters: hasDistance ? sample.distanceMeters : null,
      caloriesKcal: hasCalories ? sample.caloriesKcal : null,
      recordedAt: recordedAt,
    );
    if (snapshot == null) {
      return null;
    }
    _updates.add(snapshot);
    return snapshot;
  }

  void clear(String sessionId) {
    _states.remove(sessionId);
  }

  Future<void> dispose() async {
    await _updates.close();
  }
}

final class _ActiveWorkoutSensorAccumulator {
  _ActiveWorkoutSensorAccumulator(this.sessionId, this.firstSampleAt);

  final String sessionId;
  final DateTime firstSampleAt;
  var _latestSampleAt = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
  final _seenSampleIds = <String>{};
  int? _latestHeartRateBpm;
  var _sampleCount = 0;
  var _heartRateSampleCount = 0;
  var _heartRateSum = 0.0;
  int? _minHeartRateBpm;
  int? _maxHeartRateBpm;
  double? _latestDistanceMeters;
  double? _latestCaloriesKcal;

  ActiveWorkoutSensorState get snapshot {
    final averageHeartRateBpm = _heartRateSampleCount == 0
        ? null
        : _heartRateSum / _heartRateSampleCount;
    return ActiveWorkoutSensorState(
      sessionId: sessionId,
      firstSampleAt: firstSampleAt,
      latestSampleAt: _latestSampleAt,
      latestHeartRateBpm: _latestHeartRateBpm,
      sampleCount: _sampleCount,
      minHeartRateBpm: _minHeartRateBpm,
      averageHeartRateBpm: averageHeartRateBpm,
      maxHeartRateBpm: _maxHeartRateBpm,
      latestDistanceMeters: _latestDistanceMeters,
      latestCaloriesKcal: _latestCaloriesKcal,
      estimatedCaloriesKcal: _estimatedCaloriesKcal(
        averageHeartRateBpm: averageHeartRateBpm ?? 0,
        activeDuration: _latestSampleAt.difference(firstSampleAt),
      ),
    );
  }

  ActiveWorkoutSensorState? add({
    required String? sampleId,
    required int? heartRateBpm,
    required double? distanceMeters,
    required double? caloriesKcal,
    required DateTime recordedAt,
  }) {
    final stableSampleId = sampleId?.trim();
    if (stableSampleId != null && stableSampleId.isNotEmpty) {
      final added = _seenSampleIds.add(stableSampleId);
      if (!added) {
        return null;
      }
    }
    _sampleCount += 1;
    if (recordedAt.isAfter(_latestSampleAt)) {
      _latestSampleAt = recordedAt;
    }
    if (heartRateBpm != null) {
      _heartRateSampleCount += 1;
      _heartRateSum += heartRateBpm;
      _latestHeartRateBpm = heartRateBpm;
      _minHeartRateBpm = _minHeartRateBpm == null
          ? heartRateBpm
          : (_minHeartRateBpm! < heartRateBpm
                ? _minHeartRateBpm
                : heartRateBpm);
      _maxHeartRateBpm = _maxHeartRateBpm == null
          ? heartRateBpm
          : (_maxHeartRateBpm! > heartRateBpm
                ? _maxHeartRateBpm
                : heartRateBpm);
    }
    if (distanceMeters != null &&
        (_latestDistanceMeters == null ||
            distanceMeters >= _latestDistanceMeters!)) {
      _latestDistanceMeters = distanceMeters;
    }
    if (caloriesKcal != null &&
        (_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
      _latestCaloriesKcal = caloriesKcal;
    }
    return snapshot;
  }
}

double _estimatedCaloriesKcal({
  required double averageHeartRateBpm,
  required Duration activeDuration,
}) {
  final durationHours = activeDuration.inMilliseconds / 3600000;
  if (durationHours <= 0) {
    return 0;
  }
  final elevatedBpm = averageHeartRateBpm - 60;
  if (elevatedBpm <= 0) {
    return 0;
  }
  return elevatedBpm * durationHours * 5;
}

final class WorkoutHistoryUseCases {
  WorkoutHistoryUseCases({required this.repository, required this.clock});

  final WorkoutHistoryRepository repository;
  final Clock clock;
  final _pendingHeartRateSummaries = <String, WatchSensorSummary>{};

  Future<WorkoutHistory?> findById(String id) async {
    final history = await repository.findById(id);
    if (history == null) {
      return null;
    }
    final changed = await _applyPendingHeartRateSummaries([history]);
    return changed ? repository.findById(id) : history;
  }

  Future<List<WorkoutHistory>> listActive() async {
    final histories = await repository.listActive();
    final changed = await _applyPendingHeartRateSummaries(histories);
    return changed ? repository.listActive() : histories;
  }

  Future<void> delete(String id) => repository.delete(id, clock.now());

  Future<void> updateHeartRateSummary(WatchSensorSummary summary) async {
    if (!_isUsableHeartRateSummary(summary)) {
      return;
    }
    final sessionId = summary.sessionId.trim();
    final histories = await repository.listActive();
    final applied = await _applyHeartRateSummary(histories, summary);
    if (applied) {
      _pendingHeartRateSummaries.remove(sessionId);
      return;
    }
    if (!_hasHistoryForSummary(histories, summary)) {
      _pendingHeartRateSummaries.putIfAbsent(sessionId, () => summary);
    }
  }

  Future<bool> _applyPendingHeartRateSummaries(
    List<WorkoutHistory> histories,
  ) async {
    var changed = false;
    for (final summary in _pendingHeartRateSummaries.values.toList()) {
      final applied = await _applyHeartRateSummary(histories, summary);
      if (applied) {
        _pendingHeartRateSummaries.remove(summary.sessionId.trim());
        changed = true;
      }
    }
    return changed;
  }

  Future<bool> _applyHeartRateSummary(
    List<WorkoutHistory> histories,
    WatchSensorSummary summary,
  ) async {
    final history = _findHistoryForSummary(histories, summary);
    if (history == null ||
        history.averageHeartRateBpm != null ||
        history.maxHeartRateBpm != null ||
        history.totalDistanceMeters != null ||
        history.totalCaloriesKcal != null) {
      return false;
    }
    await repository.patchHeartRateSummary(
      historyId: history.metadata.id,
      minHeartRateBpm: summary.minHeartRateBpm,
      averageHeartRateBpm: summary.averageHeartRateBpm!,
      maxHeartRateBpm: summary.maxHeartRateBpm!,
      totalDistanceMeters: summary.totalDistanceMeters,
      totalCaloriesKcal: summary.totalCaloriesKcal,
      patchedAt: clock.now(),
    );
    return true;
  }

  bool _hasHistoryForSummary(
    List<WorkoutHistory> histories,
    WatchSensorSummary summary,
  ) {
    return _findHistoryForSummary(histories, summary) != null;
  }

  WorkoutHistory? _findHistoryForSummary(
    List<WorkoutHistory> histories,
    WatchSensorSummary summary,
  ) {
    final sessionId = summary.sessionId.trim();
    for (final candidate in histories) {
      if (candidate.metadata.id == sessionId ||
          candidate.sourceActiveWorkoutSessionId == sessionId) {
        return candidate;
      }
    }
    return null;
  }

  bool _isUsableHeartRateSummary(WatchSensorSummary summary) {
    return summary.sampleCount >= 3 &&
        summary.sessionId.trim().isNotEmpty &&
        summary.averageHeartRateBpm != null &&
        summary.averageHeartRateBpm! > 0 &&
        summary.maxHeartRateBpm != null &&
        summary.maxHeartRateBpm! > 0 &&
        (summary.minHeartRateBpm == null || summary.minHeartRateBpm! > 0) &&
        (summary.totalDistanceMeters == null ||
            summary.totalDistanceMeters! >= 0) &&
        (summary.totalCaloriesKcal == null || summary.totalCaloriesKcal! >= 0);
  }
}

final class ProgressionStatsUseCase {
  const ProgressionStatsUseCase({
    required this.repository,
    required this.clock,
  });

  final ProgressionStatsRepository repository;
  final Clock clock;

  Future<ProgressionOverview> getOverview(ProgressionPeriod period) async {
    final range = _progressionDateRange(period, clock.now());
    final global = await repository.readGlobalStats(range);
    final exerciseData = await repository.listExerciseOptions(range);
    final exerciseOptions = <ProgressionExerciseOption>[];
    for (final option in exerciseData) {
      final measures = await repository.listMeasureOptions(
        range: range,
        exerciseKey: option.exerciseKey,
      );
      exerciseOptions.add(
        ProgressionExerciseOption(
          exerciseKey: option.exerciseKey,
          nameSnapshot: option.nameSnapshot,
          isArchived: option.isArchived,
          lastPerformedAt: option.lastPerformedAt,
          measures: measures.map(_progressionMeasureOption).toList(),
        ),
      );
    }
    return ProgressionOverview(
      period: period,
      rangeLabelKind: _rangeLabelKind(period, global),
      completedSessionCount: global.completedSessionCount,
      totalActiveMs: global.totalActiveMs,
      activeWeekCount: global.activeWeekStarts.length,
      totalWeekCount: _totalWeekCount(period, range),
      hasAnyCompletedHistory: global.hasAnyCompletedHistory,
      exerciseOptions: exerciseOptions,
    );
  }

  Future<ProgressionExerciseSeries> getExerciseSeries({
    required ProgressionPeriod period,
    required String exerciseKey,
    required ProgressionMeasure measure,
  }) async {
    final range = _progressionDateRange(period, clock.now());
    final data = await repository.readExerciseSeries(
      range: range,
      exerciseKey: exerciseKey,
      measure: measure,
    );
    final points = data.points
        .map(
          (point) => ProgressionPoint(
            workoutHistoryId: point.workoutHistoryId,
            workoutNameSnapshot: point.workoutNameSnapshot,
            startedAt: point.startedAt,
            value: point.value,
            rawValue: point.rawValue,
          ),
        )
        .toList();
    return ProgressionExerciseSeries(
      exerciseKey: data.exerciseKey,
      exerciseNameSnapshot: data.exerciseNameSnapshot,
      measure: _progressionMeasureOption(data.measure),
      points: points,
      summary: _progressionSeriesSummary(measure, points),
      state: _progressionSeriesState(
        period: period,
        points: points,
        hasAnyAllTimeData: data.hasAnyAllTimeData,
        hasAnyCompletedExerciseResult: data.hasAnyCompletedExerciseResult,
      ),
    );
  }
}

ProgressionDateRange _progressionDateRange(
  ProgressionPeriod period,
  DateTime now,
) {
  final endedAt = now.toUtc();
  switch (period) {
    case ProgressionPeriod.fourWeeks:
      return ProgressionDateRange(
        startedAt: _startOfLocalWeek(
          endedAt.toLocal(),
        ).subtract(const Duration(days: 21)),
        endedAt: endedAt,
      );
    case ProgressionPeriod.threeMonths:
      return ProgressionDateRange(
        startedAt: DateTime(
          endedAt.toLocal().year,
          endedAt.toLocal().month - 3,
          endedAt.toLocal().day,
        ),
        endedAt: endedAt,
      );
    case ProgressionPeriod.all:
      return ProgressionDateRange(startedAt: null, endedAt: endedAt);
  }
}

ProgressionRangeLabelKind _rangeLabelKind(
  ProgressionPeriod period,
  ProgressionGlobalStatsData data,
) {
  if (!data.hasAnyCompletedHistory) {
    return ProgressionRangeLabelKind.none;
  }
  return period == ProgressionPeriod.all
      ? ProgressionRangeLabelKind.sinceFirstSession
      : ProgressionRangeLabelKind.sinceDate;
}

int? _totalWeekCount(ProgressionPeriod period, ProgressionDateRange range) {
  switch (period) {
    case ProgressionPeriod.fourWeeks:
      return 4;
    case ProgressionPeriod.threeMonths:
      final startedAt = range.startedAt;
      if (startedAt == null) {
        return null;
      }
      final firstWeek = _startOfLocalWeek(startedAt.toLocal());
      final lastWeek = _startOfLocalWeek(range.endedAt.toLocal());
      return lastWeek.difference(firstWeek).inDays ~/ 7 + 1;
    case ProgressionPeriod.all:
      return null;
  }
}

DateTime _startOfLocalWeek(DateTime value) {
  final localDay = DateTime(value.year, value.month, value.day);
  return localDay.subtract(Duration(days: localDay.weekday - DateTime.monday));
}

ProgressionMeasureOption _progressionMeasureOption(
  ProgressionMeasureOptionData data,
) {
  return ProgressionMeasureOption(
    measure: data.measure,
    label: data.label,
    scoreLabel: data.scoreLabel,
    scoreUnit: data.scoreUnit,
    lowerIsBetter: data.lowerIsBetter,
  );
}

ProgressionSeriesState _progressionSeriesState({
  required ProgressionPeriod period,
  required List<ProgressionPoint> points,
  required bool hasAnyAllTimeData,
  required bool hasAnyCompletedExerciseResult,
}) {
  if (points.length > 1) {
    return ProgressionSeriesState.ready;
  }
  if (points.length == 1) {
    return ProgressionSeriesState.singlePoint;
  }
  if (period != ProgressionPeriod.all && hasAnyAllTimeData) {
    return ProgressionSeriesState.emptyButHasAllTimeData;
  }
  if (hasAnyCompletedExerciseResult) {
    return ProgressionSeriesState.noGraphableMeasure;
  }
  return ProgressionSeriesState.emptyForPeriod;
}

ProgressionSeriesSummary _progressionSeriesSummary(
  ProgressionMeasure measure,
  List<ProgressionPoint> points,
) {
  if (points.isEmpty) {
    return const ProgressionSeriesSummary(
      kind: ProgressionSeriesSummaryKind.none,
    );
  }
  switch (measure) {
    case ProgressionMeasure.manualScore:
      final best = points
          .map((point) => point.rawValue as double)
          .reduce((current, next) => current > next ? current : next);
      return ProgressionSeriesSummary(
        kind: ProgressionSeriesSummaryKind.bestLast,
        best: best,
        last: points.last.rawValue,
      );
    case ProgressionMeasure.stopwatchScore:
      final best = points
          .map((point) => point.rawValue as int)
          .reduce((current, next) => current < next ? current : next);
      return ProgressionSeriesSummary(
        kind: ProgressionSeriesSummaryKind.bestLast,
        best: best,
        last: points.last.rawValue,
      );
    case ProgressionMeasure.reps:
    case ProgressionMeasure.time:
      final recentTotal = points
          .map((point) => point.rawValue as int)
          .fold<int>(0, (total, value) => total + value);
      return ProgressionSeriesSummary(
        kind: ProgressionSeriesSummaryKind.totals,
        recentTotal: recentTotal,
        lastSessionTotal: points.last.rawValue,
      );
  }
}

enum SetPositionStatus { pending, completed, skipped }

final class SetResultPositionState {
  const SetResultPositionState({
    required this.programIndex,
    required this.exerciseIndex,
    required this.setIndex,
    required this.status,
    this.result,
  });

  final int programIndex;
  final int exerciseIndex;
  final int setIndex;
  final SetPositionStatus status;
  final ActiveSetResult? result;
}

List<WorkoutTemplateProgram> _repositionWorkoutTemplatePrograms(
  List<WorkoutTemplateProgram> programs,
  DateTime now,
) {
  return [
    for (var index = 0; index < programs.length; index++)
      WorkoutTemplateProgram(
        metadata: programs[index].metadata.touch(now),
        workoutTemplateId: programs[index].workoutTemplateId,
        sourceProgramId: programs[index].sourceProgramId,
        position: index,
        programNameSnapshot: programs[index].programNameSnapshot,
        defaultRestSecondsSnapshot: programs[index].defaultRestSecondsSnapshot,
        programSnapshotJson: programs[index].programSnapshotJson,
      ),
  ];
}

List<String> _resolveExerciseImageIds(
  String? imageMediaId,
  List<String> imageMediaIds,
) {
  if (imageMediaId != null) {
    return [imageMediaId];
  }
  return imageMediaIds;
}

List<String> _appendExerciseImage(List<String> currentIds, String mediaId) {
  if (currentIds.contains(mediaId)) {
    return currentIds;
  }
  if (currentIds.length >= 5) {
    throw const DomainException('An exercise cannot have more than 5 images.');
  }
  return [...currentIds, mediaId];
}

bool _sameImageSet(List<String> left, List<String> right) {
  if (left.length != right.length) {
    return false;
  }
  return left.toSet().containsAll(right) && right.toSet().containsAll(left);
}

void _validateExerciseSteps(List<ExerciseStep> steps) {
  if (steps.length > 8) {
    throw const DomainException('An exercise cannot have more than 8 steps.');
  }
  for (var index = 0; index < steps.length; index++) {
    final step = steps[index];
    if (step.position != index) {
      throw const DomainException(
        'Exercise step positions must be contiguous and ordered.',
      );
    }
    if (step.name.trim().isEmpty) {
      throw const DomainException('Exercise step name must not be blank.');
    }
    if (step.defaultTargetValue <= 0) {
      throw const DomainException(
        'Exercise step default target value must be positive.',
      );
    }
    if (!step.hasScore) {
      if (step.scoreLabel != null ||
          step.scoreUnit != null ||
          step.defaultTargetScore != null ||
          step.defaultTargetScoreTimeMs != null ||
          step.linkedToSeriesScore) {
        throw const DomainException(
          'Disabled exercise step score must not define score values.',
        );
      }
      continue;
    }
    if (step.defaultTargetScore != null &&
        step.defaultTargetScoreTimeMs != null) {
      throw const DomainException(
        'Manual and stopwatch step score targets cannot both be set.',
      );
    }
    switch (step.scoreInputMode) {
      case ScoreInputMode.manual:
        if (step.scoreLabel == null || step.scoreLabel!.trim().isEmpty) {
          throw const DomainException(
            'Exercise step score label must not be blank.',
          );
        }
        if (step.scoreUnit == null || step.scoreUnit!.trim().isEmpty) {
          throw const DomainException(
            'Exercise step score unit must not be blank.',
          );
        }
        if (step.defaultTargetScore != null && step.defaultTargetScore! < 0) {
          throw const DomainException(
            'Exercise step default target score must not be negative.',
          );
        }
        if (step.defaultTargetScoreTimeMs != null) {
          throw const DomainException(
            'Manual exercise step score cannot define stopwatch target.',
          );
        }
      case ScoreInputMode.stopwatch:
        if (step.linkedToSeriesScore) {
          throw const DomainException(
            'Linked series score requires manual exercise step score.',
          );
        }
        if (step.scoreLabel != null ||
            step.scoreUnit != null ||
            step.defaultTargetScore != null) {
          throw const DomainException(
            'Stopwatch exercise step score cannot define manual score values.',
          );
        }
        if (step.defaultTargetScoreTimeMs != null &&
            step.defaultTargetScoreTimeMs! <= 0) {
          throw const DomainException(
            'Exercise step default target score time ms must be positive.',
          );
        }
    }
  }
}

final class ExercisePerformanceReferenceUseCase {
  const ExercisePerformanceReferenceUseCase({required this.repository});

  final ExercisePerformanceReferenceRepository repository;

  Future<ExercisePerformanceReference> getExercisePerformanceReference({
    required String exerciseId,
    required ActivePerformanceMeasures activeMeasures,
    required int currentSetIndex,
  }) async {
    final hasAnyHistory = await repository.hasAnyCompletedHistoryForExercise(
      exerciseId,
    );
    if (!activeMeasures.hasAny) {
      return ExercisePerformanceReference(
        hasAnyHistoryForExercise: hasAnyHistory,
      );
    }

    final last = await repository.findLatestSetPerformance(
      exerciseId: exerciseId,
      activeMeasures: activeMeasures,
      currentSetIndex: currentSetIndex,
    );
    final recordMetric = _recordMetricFor(activeMeasures);
    final record = recordMetric == null
        ? null
        : await repository.findBestMetricPerformance(
            exerciseId: exerciseId,
            metric: recordMetric,
            scoreInputMode: activeMeasures.scoreInputMode,
          );
    return ExercisePerformanceReference(
      hasAnyHistoryForExercise: hasAnyHistory,
      last: last,
      record: record,
    );
  }
}

PerformanceMetric? _recordMetricFor(ActivePerformanceMeasures measures) {
  if (measures.scoreEnabled) {
    return PerformanceMetric.score;
  }
  if (measures.repsEnabled) {
    return PerformanceMetric.reps;
  }
  if (measures.timeEnabled) {
    return PerformanceMetric.time;
  }
  return null;
}

void _validateAuthInput({required String email, required String password}) {
  final normalizedEmail = email.trim();
  final hasBasicEmailShape = RegExp(
    r'^[^@\s]+@[^@\s]+\.[^@\s]+$',
  ).hasMatch(normalizedEmail);
  if (!hasBasicEmailShape) {
    throw const DomainException('Email format is invalid.');
  }
  if (password.length < 8) {
    throw const DomainException('Password must contain at least 8 characters.');
  }
}

void _validateExerciseDefaultTargets({
  required bool hasTimeMeasure,
  required bool hasRepsMeasure,
  required bool hasScoreMeasure,
  required ScoreInputMode scoreInputMode,
  required int? defaultTargetTimeSeconds,
  required int? defaultTargetReps,
  required double? defaultTargetScore,
  required int? defaultTargetScoreTimeMs,
}) {
  if (hasTimeMeasure &&
      (defaultTargetTimeSeconds == null || defaultTargetTimeSeconds <= 0)) {
    throw const DomainException(
      'Default target time seconds must be positive.',
    );
  }
  if (hasRepsMeasure && (defaultTargetReps == null || defaultTargetReps <= 0)) {
    throw const DomainException('Default target reps must be positive.');
  }
  if (hasScoreMeasure &&
      scoreInputMode == ScoreInputMode.manual &&
      (defaultTargetScore == null || defaultTargetScore < 0)) {
    throw const DomainException('Default target score must not be negative.');
  }
  if (defaultTargetScore != null &&
      (!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) {
    throw const DomainException(
      'Default target score requires manual score mode.',
    );
  }
  if (defaultTargetScoreTimeMs != null && defaultTargetScoreTimeMs <= 0) {
    throw const DomainException(
      'Default target score time ms must be positive.',
    );
  }
  if (defaultTargetScoreTimeMs != null &&
      (!hasScoreMeasure || scoreInputMode != ScoreInputMode.stopwatch)) {
    throw const DomainException(
      'Default target score time ms requires stopwatch score mode.',
    );
  }
}

void _validateOverrideTargets(
  String programSnapshotJson,
  WorkoutTemplateExerciseOverrideConfig input,
) {
  final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
  final exercises = (snapshot['exercises'] as List<dynamic>? ?? const [])
      .cast<Map<String, dynamic>>();
  final exercise = exercises.cast<Map<String, dynamic>?>().firstWhere(
    (item) =>
        item?['id'] == input.snapshotProgramExerciseId ||
        item?['snapshotProgramExerciseId'] == input.snapshotProgramExerciseId,
    orElse: () => null,
  );
  if (exercise == null) {
    throw const DomainException('Snapshot exercise not found.');
  }
  if (input.targetTimeSecondsOverride != null &&
      exercise['timeEnabled'] != true) {
    throw const DomainException('Cannot override inactive time target.');
  }
  if (input.targetRepsOverride != null && exercise['repsEnabled'] != true) {
    throw const DomainException('Cannot override inactive reps target.');
  }
  if (input.targetScoreOverride != null && exercise['scoreEnabled'] != true) {
    throw const DomainException('Cannot override inactive score target.');
  }
  if (input.targetScoreTimeMsOverride != null &&
      exercise['scoreEnabled'] != true) {
    throw const DomainException('Cannot override inactive score target.');
  }
  final mode = _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
  if (input.targetScoreOverride != null && mode != ScoreInputMode.manual) {
    throw const DomainException(
      'Cannot override manual score target on stopwatch score mode.',
    );
  }
  if (input.targetScoreTimeMsOverride != null &&
      mode != ScoreInputMode.stopwatch) {
    throw const DomainException(
      'Cannot override stopwatch score target on manual score mode.',
    );
  }
}

void _ensureEditablePastPosition({
  required ActiveWorkoutSession session,
  required int programIndex,
  required int exerciseIndex,
  required int setIndex,
}) {
  final comparison = _comparePositions(
    programIndex,
    exerciseIndex,
    setIndex,
    session.currentProgramIndex,
    session.currentExerciseIndex,
    session.currentSetIndex,
  );
  if (comparison >= 0) {
    throw const DomainException(
      'Only past set results can be edited outside the normal execution flow.',
    );
  }
}

int _comparePositions(
  int leftProgram,
  int leftExercise,
  int leftSet,
  int rightProgram,
  int rightExercise,
  int rightSet,
) {
  final programComparison = leftProgram.compareTo(rightProgram);
  if (programComparison != 0) {
    return programComparison;
  }
  final exerciseComparison = leftExercise.compareTo(rightExercise);
  if (exerciseComparison != 0) {
    return exerciseComparison;
  }
  return leftSet.compareTo(rightSet);
}

String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
  return '$programIndex:$exerciseIndex:$setIndex';
}

String _syncKey(SyncResourceType resourceType, String clientId) {
  return '${resourceType.name}:$clientId';
}

Map<String, Object?> _programSharePayload(Program program) => {
  'metadata': _metadataSharePayload(program.metadata),
  'id': program.metadata.id,
  'name': program.name,
  'defaultRestSeconds': program.defaultRestSeconds,
  'exercises': program.exercises
      .map((exercise) => exercise.toSnapshotJson())
      .toList(),
};

Map<String, Object?> _workoutTemplateSharePayload(WorkoutTemplate template) => {
  'metadata': _metadataSharePayload(template.metadata),
  'id': template.metadata.id,
  'name': template.name,
  'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
  'programs': template.programs
      .map(
        (program) => {
          'id': program.metadata.id,
          'sourceProgramId': program.sourceProgramId,
          'position': program.position,
          'programNameSnapshot': program.programNameSnapshot,
          'defaultRestSecondsSnapshot': program.defaultRestSecondsSnapshot,
          'programSnapshotJson': program.programSnapshotJson,
        },
      )
      .toList(),
  'overrides': template.overrides
      .map(
        (override) => {
          'id': override.metadata.id,
          'workoutTemplateProgramId': override.workoutTemplateProgramId,
          'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
          'setsCountOverride': override.setsCountOverride,
          'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
          'targetRepsOverride': override.targetRepsOverride,
          'targetScoreOverride': override.targetScoreOverride,
          'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
          'autoStartNextTimedStepOverride':
              override.autoStartNextTimedStepOverride,
        },
      )
      .toList(),
};

Map<String, Object?> _packSharePayload(
  String name,
  List<WorkoutTemplate> templates,
) => {
  'name': name,
  'workouts': templates.map(_workoutTemplateSharePayload).toList(),
};

Map<String, Object?> _metadataSharePayload(EntityMetadata metadata) => {
  'id': metadata.id,
  'createdAt': metadata.createdAt.toUtc().toIso8601String(),
  'updatedAt': metadata.updatedAt.toUtc().toIso8601String(),
  'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(),
  'schemaVersion': metadata.schemaVersion,
  'syncState': metadata.syncState.name,
  'localRevision': metadata.localRevision,
  'originDeviceId': metadata.originDeviceId,
  'futureOwnerProfileId': metadata.futureOwnerProfileId,
  'lastSyncedAt': metadata.lastSyncedAt?.toUtc().toIso8601String(),
  'remoteRevision': metadata.remoteRevision,
};

Map<String, Object?> _jsonObject(String? json) {
  if (json == null) {
    return const {};
  }
  final decoded = jsonDecode(json);
  if (decoded is Map) {
    return Map<String, Object?>.from(decoded);
  }
  return const {};
}

String _stringFromSharePayload(
  Map<String, Object?> payload,
  String key,
  String fallback,
) {
  final value = payload[key];
  return value is String && value.trim().isNotEmpty ? value : fallback;
}

List<String> _stringListFromSharePayload(Object? value) {
  if (value is! List) {
    return const [];
  }
  return value.whereType<String>().toList(growable: false);
}

List<String> _jsonStringList(String? json) {
  if (json == null) {
    return const [];
  }
  final decoded = jsonDecode(json);
  if (decoded is List) {
    return decoded.whereType<String>().toList(growable: false);
  }
  return const [];
}

ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
  return switch (value) {
    'stopwatch' => ScoreInputMode.stopwatch,
    _ => ScoreInputMode.manual,
  };
}

List<String> _stringListFromSnapshot(Object? value) {
  if (value is! List) {
    return const [];
  }
  return value.whereType<String>().toList(growable: false);
}

_SetPositionSnapshot? _findSetSnapshot({
  required String resolvedTemplateSnapshotJson,
  required int programIndex,
  required int exerciseIndex,
  required int setIndex,
}) {
  for (final snapshot in _listSetSnapshots(resolvedTemplateSnapshotJson)) {
    if (snapshot.programIndex == programIndex &&
        snapshot.exerciseIndex == exerciseIndex &&
        snapshot.setIndex == setIndex) {
      return snapshot;
    }
  }
  return null;
}

_SetPositionSnapshot? _nextPosition(
  String resolvedTemplateSnapshotJson,
  ActiveWorkoutSession session,
) {
  return _nextPositionAfter(
    resolvedTemplateSnapshotJson,
    programIndex: session.currentProgramIndex,
    exerciseIndex: session.currentExerciseIndex,
    setIndex: session.currentSetIndex,
  );
}

_SetPositionSnapshot? _nextPositionAfter(
  String resolvedTemplateSnapshotJson, {
  required int programIndex,
  required int exerciseIndex,
  required int setIndex,
}) {
  final snapshots = _listSetSnapshots(resolvedTemplateSnapshotJson);
  final currentIndex = snapshots.indexWhere(
    (snapshot) =>
        snapshot.programIndex == programIndex &&
        snapshot.exerciseIndex == exerciseIndex &&
        snapshot.setIndex == setIndex,
  );
  if (currentIndex == -1 || currentIndex + 1 >= snapshots.length) {
    return null;
  }
  return snapshots[currentIndex + 1];
}

String _resolvedTemplateSnapshotJson(WorkoutTemplate template) {
  return jsonEncode({
    'templateId': template.metadata.id,
    'name': template.name,
    'programs': template.programs
        .map(
          (program) => {
            'id': program.metadata.id,
            'programNameSnapshot': program.programNameSnapshot,
            'programSnapshotJson': program.programSnapshotJson,
          },
        )
        .toList(),
    'overrides': template.overrides
        .map(
          (override) => {
            'workoutTemplateProgramId': override.workoutTemplateProgramId,
            'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
            'setsCountOverride': override.setsCountOverride,
            'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
            'targetRepsOverride': override.targetRepsOverride,
            'targetScoreOverride': override.targetScoreOverride,
            'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
            'autoStartNextTimedStepOverride':
                override.autoStartNextTimedStepOverride,
          },
        )
        .toList(),
  });
}

void _ensurePlayableResolvedTemplateSnapshot(
  String resolvedTemplateSnapshotJson,
) {
  if (!_isPlayableResolvedTemplateSnapshot(resolvedTemplateSnapshotJson)) {
    throw const DomainException(
      'Workout template must contain at least one exercise.',
    );
  }
}

bool _isPlayableResolvedTemplateSnapshot(String resolvedTemplateSnapshotJson) {
  try {
    return _listSetSnapshots(resolvedTemplateSnapshotJson).isNotEmpty;
  } on Object {
    return false;
  }
}

_ResolvedExerciseSnapshot? _findExerciseSnapshot({
  required String resolvedTemplateSnapshotJson,
  required int programIndex,
  required int exerciseIndex,
}) {
  final decoded =
      jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
  final programs = (decoded['programs'] as List<dynamic>? ?? const []);
  if (programIndex < 0 || programIndex >= programs.length) {
    return null;
  }
  final program = programs[programIndex] as Map<String, dynamic>;
  final programSnapshotId = program['id'] as String? ?? 'program-$programIndex';
  final programName = program['programNameSnapshot'] as String? ?? '';
  final programSnapshotJson = program['programSnapshotJson'] as String?;
  if (programSnapshotJson == null) {
    return null;
  }
  final programSnapshot =
      jsonDecode(programSnapshotJson) as Map<String, dynamic>;
  final exercises =
      (programSnapshot['exercises'] as List<dynamic>? ?? const []);
  if (exerciseIndex < 0 || exerciseIndex >= exercises.length) {
    return null;
  }
  final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
  final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
  final autoStartNextTimedStepEffective =
      _templateStepChainingOverride(
        decoded,
        workoutTemplateProgramId: programSnapshotId,
        snapshotProgramExerciseId: id,
      ) ??
      (exercise['autoStartNextTimedStepOverride'] as bool?) ??
      (exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
      true;
  return _ResolvedExerciseSnapshot(
    programSnapshotId: programSnapshotId,
    exerciseSnapshotId: id,
    programNameSnapshot: programName,
    exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
    timeEnabled: exercise['timeEnabled'] == true,
    repsEnabled: exercise['repsEnabled'] == true,
    scoreEnabled: exercise['scoreEnabled'] == true,
    targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
    targetReps: exercise['targetReps'] as int?,
    targetScore: (exercise['targetScore'] as num?)?.toDouble(),
    targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
    scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
      exercise['scoreInputModeSnapshot'],
    ),
    scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
    scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
    setsCount: exercise['setsCount'] as int? ?? 0,
    restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
    steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
    autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
  );
}

const _legacyHealthServicesExerciseTypeStrategy = [
  'RUNNING',
  'WALKING',
  'HIGH_INTENSITY_INTERVAL_TRAINING',
  'WORKOUT',
];

const _knownHealthServicesExerciseTypeNames = {
  'RUNNING',
  'WALKING',
  'HIGH_INTENSITY_INTERVAL_TRAINING',
  'WORKOUT',
};

List<String> _sessionHealthServicesExerciseTypeStrategy(
  String resolvedTemplateSnapshotJson,
) {
  try {
    final decoded =
        jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
    final programs = (decoded['programs'] as List<dynamic>? ?? const []);
    final output = <String>[];
    void add(String type) {
      if (_knownHealthServicesExerciseTypeNames.contains(type) &&
          !output.contains(type)) {
        output.add(type);
      }
    }

    for (final rawProgram in programs) {
      if (rawProgram is! Map) {
        continue;
      }
      final program = Map<String, dynamic>.from(rawProgram);
      final programSnapshotJson = program['programSnapshotJson'] as String?;
      if (programSnapshotJson == null) {
        continue;
      }
      final programSnapshot =
          jsonDecode(programSnapshotJson) as Map<String, dynamic>;
      final exercises =
          (programSnapshot['exercises'] as List<dynamic>? ?? const []);
      for (final rawExercise in exercises) {
        if (rawExercise is! Map) {
          continue;
        }
        final exercise = Map<String, dynamic>.from(rawExercise);
        for (final type in _stringListFromSnapshot(
          exercise['healthServicesExerciseTypeStrategy'],
        )) {
          add(type);
        }
      }
    }
    return output.isEmpty
        ? _legacyHealthServicesExerciseTypeStrategy
        : List.unmodifiable(output);
  } on Object {
    return _legacyHealthServicesExerciseTypeStrategy;
  }
}

bool? _templateStepChainingOverride(
  Map<String, dynamic> resolvedTemplateSnapshot, {
  required String workoutTemplateProgramId,
  required String snapshotProgramExerciseId,
}) {
  final overrides =
      (resolvedTemplateSnapshot['overrides'] as List<dynamic>? ?? const []);
  for (final raw in overrides) {
    if (raw is! Map) {
      continue;
    }
    final override = Map<String, dynamic>.from(raw);
    if (override['workoutTemplateProgramId'] == workoutTemplateProgramId &&
        override['snapshotProgramExerciseId'] == snapshotProgramExerciseId) {
      final value = override['autoStartNextTimedStepOverride'];
      return value is bool ? value : null;
    }
  }
  return null;
}

List<_SetPositionSnapshot> _listSetSnapshots(
  String resolvedTemplateSnapshotJson,
) {
  final decoded =
      jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
  final programs = (decoded['programs'] as List<dynamic>? ?? const []);
  final snapshots = <_SetPositionSnapshot>[];
  for (var programIndex = 0; programIndex < programs.length; programIndex++) {
    final program = programs[programIndex] as Map<String, dynamic>;
    final programSnapshotId =
        program['id'] as String? ?? 'program-$programIndex';
    final programSnapshotJson = program['programSnapshotJson'] as String?;
    if (programSnapshotJson == null) {
      continue;
    }
    final programSnapshot =
        jsonDecode(programSnapshotJson) as Map<String, dynamic>;
    final exercises =
        (programSnapshot['exercises'] as List<dynamic>? ?? const []);
    for (
      var exerciseIndex = 0;
      exerciseIndex < exercises.length;
      exerciseIndex++
    ) {
      final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
      final exerciseSnapshotId =
          exercise['id'] as String? ?? 'exercise-$exerciseIndex';
      final setsCount = exercise['setsCount'] as int? ?? 0;
      for (var setIndex = 0; setIndex < setsCount; setIndex++) {
        snapshots.add(
          _SetPositionSnapshot(
            programSnapshotId: programSnapshotId,
            exerciseSnapshotId: exerciseSnapshotId,
            programIndex: programIndex,
            exerciseIndex: exerciseIndex,
            setIndex: setIndex,
            scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
            scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
            scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
              exercise['scoreInputModeSnapshot'],
            ),
            restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
          ),
        );
      }
    }
  }
  return snapshots;
}

final class _SetPositionSnapshot {
  const _SetPositionSnapshot({
    required this.programSnapshotId,
    required this.exerciseSnapshotId,
    required this.programIndex,
    required this.exerciseIndex,
    required this.setIndex,
    this.scoreLabelSnapshot,
    this.scoreUnitSnapshot,
    required this.scoreInputModeSnapshot,
    required this.restSeconds,
  });

  final String programSnapshotId;
  final String exerciseSnapshotId;
  final int programIndex;
  final int exerciseIndex;
  final int setIndex;
  final String? scoreLabelSnapshot;
  final String? scoreUnitSnapshot;
  final ScoreInputMode scoreInputModeSnapshot;
  final int restSeconds;
}

final class _StepSequenceContext {
  const _StepSequenceContext({
    required this.sessionId,
    required this.programSnapshotId,
    required this.exerciseSnapshotId,
    required this.programIndex,
    required this.exerciseIndex,
    required this.setIndex,
    required this.steps,
    required this.scoreEnabled,
    required this.scoreInputMode,
    required this.expectedPassages,
    required this.autoStartNextTimedStep,
  });

  final String sessionId;
  final String programSnapshotId;
  final String exerciseSnapshotId;
  final int programIndex;
  final int exerciseIndex;
  final int setIndex;
  final List<ExerciseStep> steps;
  final bool scoreEnabled;
  final ScoreInputMode scoreInputMode;
  final int expectedPassages;
  final bool autoStartNextTimedStep;
}

List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
  required String historyId,
  required List<ActiveSetResult> results,
  required String resolvedTemplateSnapshotJson,
  required DateTime now,
  required IdGenerator ids,
  required String originDeviceId,
}) {
  final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson);
  return results.map((result) {
    final snapshot = snapshots[result.exerciseSnapshotId];
    return WorkoutHistorySetResult(
      metadata: _newMetadata(ids, originDeviceId, now),
      workoutHistoryId: historyId,
      programSnapshotId: result.programSnapshotId,
      exerciseSnapshotId: result.exerciseSnapshotId,
      programIndex: result.programIndex,
      exerciseIndex: result.exerciseIndex,
      setIndex: result.setIndex,
      programNameSnapshot:
          snapshot?.programNameSnapshot ?? result.programSnapshotId,
      exerciseNameSnapshot:
          snapshot?.exerciseNameSnapshot ?? result.exerciseSnapshotId,
      timeEnabledSnapshot: snapshot?.timeEnabled ?? result.actualTimeMs != null,
      repsEnabledSnapshot: snapshot?.repsEnabled ?? result.actualReps != null,
      scoreEnabledSnapshot:
          snapshot?.scoreEnabled ??
          (result.actualScore != null || result.actualScoreTimeMs != null),
      targetTimeSecondsSnapshot: snapshot?.targetTimeSeconds,
      targetRepsSnapshot: snapshot?.targetReps,
      targetScoreSnapshot: snapshot?.targetScore,
      targetScoreTimeMsSnapshot: snapshot?.targetScoreTimeMs,
      actualTimeMs: result.actualTimeMs,
      actualReps: result.actualReps,
      actualScore: result.actualScore,
      actualScoreTimeMs: result.actualScoreTimeMs,
      scoreInputModeSnapshot:
          snapshot?.scoreInputModeSnapshot ?? result.scoreInputModeSnapshot,
      scoreLabelSnapshot:
          result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot,
      scoreUnitSnapshot:
          result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot,
      sourceExerciseIdSnapshot: snapshot?.sourceExerciseId,
      startedAt: result.startedAt,
      completedAt: result.completedAt,
      status: result.status,
    );
  }).toList();
}

Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
  String resolvedTemplateSnapshotJson,
) {
  final decoded =
      jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
  final programs = (decoded['programs'] as List<dynamic>? ?? const []);
  final snapshots = <String, _ResolvedExerciseSnapshot>{};
  for (final program in programs.cast<Map<String, dynamic>>()) {
    final programName = program['programNameSnapshot'] as String? ?? '';
    final programSnapshotJson = program['programSnapshotJson'] as String?;
    if (programSnapshotJson == null) {
      continue;
    }
    final programSnapshot =
        jsonDecode(programSnapshotJson) as Map<String, dynamic>;
    final exercises =
        (programSnapshot['exercises'] as List<dynamic>? ?? const []);
    for (final exercise in exercises.cast<Map<String, dynamic>>()) {
      final id = exercise['id'] as String?;
      if (id == null) {
        continue;
      }
      snapshots[id] = _ResolvedExerciseSnapshot(
        programSnapshotId: program['id'] as String? ?? id,
        exerciseSnapshotId: id,
        programNameSnapshot: programName,
        exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
        sourceExerciseId: exercise['sourceExerciseId'] as String?,
        timeEnabled: exercise['timeEnabled'] == true,
        repsEnabled: exercise['repsEnabled'] == true,
        scoreEnabled: exercise['scoreEnabled'] == true,
        targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
        targetReps: exercise['targetReps'] as int?,
        targetScore: (exercise['targetScore'] as num?)?.toDouble(),
        targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
        scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
          exercise['scoreInputModeSnapshot'],
        ),
        scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
        scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
        setsCount: exercise['setsCount'] as int? ?? 0,
        restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
        steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
        autoStartNextTimedStepEffective:
            (exercise['autoStartNextTimedStepOverride'] as bool?) ??
            (exercise['autoStartNextTimedStepSnapshot'] as bool?) ??
            true,
      );
    }
  }
  return snapshots;
}

final class _ResolvedExerciseSnapshot {
  const _ResolvedExerciseSnapshot({
    required this.programSnapshotId,
    required this.exerciseSnapshotId,
    required this.programNameSnapshot,
    required this.exerciseNameSnapshot,
    this.sourceExerciseId,
    required this.timeEnabled,
    required this.repsEnabled,
    required this.scoreEnabled,
    this.targetTimeSeconds,
    this.targetReps,
    this.targetScore,
    this.targetScoreTimeMs,
    required this.scoreInputModeSnapshot,
    this.scoreLabelSnapshot,
    this.scoreUnitSnapshot,
    required this.setsCount,
    required this.restSeconds,
    this.steps = const [],
    this.autoStartNextTimedStepEffective = true,
  });

  final String programSnapshotId;
  final String exerciseSnapshotId;
  final String programNameSnapshot;
  final String exerciseNameSnapshot;
  final String? sourceExerciseId;
  final bool timeEnabled;
  final bool repsEnabled;
  final bool scoreEnabled;
  final int? targetTimeSeconds;
  final int? targetReps;
  final double? targetScore;
  final int? targetScoreTimeMs;
  final ScoreInputMode scoreInputModeSnapshot;
  final String? scoreLabelSnapshot;
  final String? scoreUnitSnapshot;
  final int setsCount;
  final int restSeconds;
  final List<ExerciseStep> steps;
  final bool autoStartNextTimedStepEffective;
}

List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {
  if (value is! List) {
    return const [];
  }
  return value
      .map((item) {
        final json = Map<String, dynamic>.from(item as Map);
        return ExerciseStep(
          id: json['id'] as String,
          position: json['position'] as int,
          name: json['name'] as String,
          type: switch (json['type']) {
            'time' => ExerciseStepType.time,
            'reps' => ExerciseStepType.reps,
            _ => throw const DomainException('Unknown exercise step type.'),
          },
          defaultTargetValue: json['defaultTargetValue'] as int,
          hasScore: json['hasScore'] == true,
          scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']),
          scoreLabel: json['scoreLabel'] as String?,
          scoreUnit: json['scoreUnit'] as String?,
          defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(),
          defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?,
          linkedToSeriesScore: json['linkedToSeriesScore'] == true,
        );
      })
      .toList(growable: false);
}

List<WorkoutHistoryStepResult> _historyStepResultsFromActiveResults({
  required String historyId,
  required List<ActiveExerciseStepResult> results,
  required String resolvedTemplateSnapshotJson,
  required DateTime now,
  required IdGenerator ids,
  required String originDeviceId,
}) {
  final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson);
  return results.map((result) {
    final snapshot = snapshots[result.exerciseSnapshotId];
    return WorkoutHistoryStepResult(
      metadata: _newMetadata(ids, originDeviceId, now),
      workoutHistoryId: historyId,
      programSnapshotId: result.programSnapshotId,
      exerciseSnapshotId: result.exerciseSnapshotId,
      programIndex: result.programIndex,
      exerciseIndex: result.exerciseIndex,
      setIndex: result.setIndex,
      passageIndex: result.passageIndex,
      stepIndex: result.stepIndex,
      stepSnapshotId: result.stepSnapshotId,
      stepNameSnapshot: result.stepNameSnapshot,
      stepTypeSnapshot: result.stepTypeSnapshot,
      targetValueSnapshot: result.targetValueSnapshot,
      hasScoreSnapshot: result.hasScoreSnapshot,
      scoreInputModeSnapshot: result.scoreInputModeSnapshot,
      scoreLabelSnapshot: result.scoreLabelSnapshot,
      scoreUnitSnapshot: result.scoreUnitSnapshot,
      targetScoreSnapshot: result.targetScoreSnapshot,
      targetScoreTimeMsSnapshot: result.targetScoreTimeMsSnapshot,
      status: result.status,
      startedAt: result.startedAt,
      completedAt: result.completedAt,
      actualTimeMs: result.actualTimeMs,
      actualReps: result.actualReps,
      actualScore: result.actualScore,
      actualScoreTimeMs: result.actualScoreTimeMs,
      note: result.note,
      sourceExerciseIdSnapshot: snapshot?.sourceExerciseId,
    );
  }).toList();
}

EntityMetadata _newMetadata(
  IdGenerator ids,
  String originDeviceId,
  DateTime now,
) {
  return _newMetadataWithId(ids.newId(), originDeviceId, now);
}

EntityMetadata _newMetadataWithId(
  String id,
  String originDeviceId,
  DateTime now,
) {
  return EntityMetadata(
    id: id,
    createdAt: now,
    updatedAt: now,
    originDeviceId: originDeviceId,
  );
}

String _copyName(String sourceName, Iterable<String> existingNames) {
  final normalizedNames = existingNames
      .map((name) => name.trim().toLowerCase())
      .toSet();
  final firstCandidate = 'Copie de $sourceName';
  if (!normalizedNames.contains(firstCandidate.trim().toLowerCase())) {
    return firstCandidate;
  }
  var copyNumber = 2;
  while (true) {
    final candidate = 'Copie $copyNumber de $sourceName';
    if (!normalizedNames.contains(candidate.trim().toLowerCase())) {
      return candidate;
    }
    copyNumber += 1;
  }
}

Map<String, Object?> _countsToJson(LocalBackupCounts counts) => {
  'exercises': counts.exercises,
  'programs': counts.programs,
  'workoutTemplates': counts.workoutTemplates,
  'workoutHistories': counts.workoutHistories,
  'mediaAssets': counts.mediaAssets,
  'embeddedMediaFiles': counts.embeddedMediaFiles,
};

Map<String, Object?> _mediaFileToJson(EmbeddedBackupMediaFile file) => {
  'mediaAssetId': file.mediaAssetId,
  'role': file.role,
  'fileName': file.fileName,
  'mimeType': file.mimeType,
  'sizeBytes': file.sizeBytes,
  'sha256': file.sha256,
  'base64': file.base64,
};

List<LocalBackupResource> _resourcesFromBackup(
  Map<String, Object?> data,
  String key,
) {
  final value = data[key];
  if (value is! List) {
    throw const LocalBackupException(LocalBackupValidationError.corrupted);
  }
  return value
      .map((item) {
        if (item is! Map) {
          throw const LocalBackupException(
            LocalBackupValidationError.corrupted,
          );
        }
        final payload = Map<String, Object?>.from(item);
        return LocalBackupResource(
          id: _stringFromBackup(payload, 'id'),
          updatedAt: _updatedAtFromBackupPayload(payload),
          payload: payload,
        );
      })
      .toList(growable: false);
}

List<EmbeddedBackupMediaFile> _mediaFilesFromBackup(Object? value) {
  if (value == null) {
    return const [];
  }
  if (value is! List) {
    throw const LocalBackupException(LocalBackupValidationError.corrupted);
  }
  return value
      .map((item) {
        if (item is! Map) {
          throw const LocalBackupException(
            LocalBackupValidationError.corrupted,
          );
        }
        final payload = Map<String, Object?>.from(item);
        return EmbeddedBackupMediaFile(
          mediaAssetId: _stringFromBackup(payload, 'mediaAssetId'),
          role: payload['role'] as String? ?? 'original',
          fileName: _stringFromBackup(payload, 'fileName'),
          mimeType: payload['mimeType'] as String?,
          sizeBytes: payload['sizeBytes'] as int? ?? 0,
          sha256: _stringFromBackup(payload, 'sha256'),
          base64: _stringFromBackup(payload, 'base64'),
        );
      })
      .toList(growable: false);
}

String _stringFromBackup(Map<String, Object?> payload, String key) {
  final value = payload[key];
  if (value is String && value.isNotEmpty) {
    return value;
  }
  throw const LocalBackupException(LocalBackupValidationError.corrupted);
}

DateTime _updatedAtFromBackupPayload(Map<String, Object?> payload) {
  final metadata = payload['metadata'];
  if (metadata is! Map) {
    throw const LocalBackupException(LocalBackupValidationError.corrupted);
  }
  return _dateTimeFromBackup(Map<String, Object?>.from(metadata)['updatedAt']);
}

DateTime _dateTimeFromBackup(Object? value) {
  if (value is! String) {
    throw const LocalBackupException(LocalBackupValidationError.corrupted);
  }
  final parsed = DateTime.tryParse(value);
  if (parsed == null) {
    throw const LocalBackupException(LocalBackupValidationError.corrupted);
  }
  return parsed.toUtc();
}

int _missingMediaCount(LocalDataExportSnapshot snapshot) {
  final embeddedIds = snapshot.mediaFiles
      .map((file) => file.mediaAssetId)
      .toSet();
  return snapshot.mediaAssets
      .where((asset) => !embeddedIds.contains(asset.id))
      .length;
}

int _missingRestoredMediaCount(
  LocalDataExportSnapshot snapshot,
  Map<String, RestoredMediaFile> restoredFiles,
) {
  return snapshot.mediaAssets
      .where((asset) => !restoredFiles.containsKey(asset.id))
      .length;
}

bool _hasNameDuplicates(LocalDataExportSnapshot snapshot) {
  return _hasDuplicatesByName(snapshot.exercises) ||
      _hasDuplicatesByName(snapshot.programs) ||
      _hasDuplicatesByName(snapshot.workoutTemplates);
}

bool _hasDuplicatesByName(List<LocalBackupResource> resources) {
  final names = <String>{};
  for (final resource in resources) {
    final name = resource.payload['name'];
    if (name is! String) {
      continue;
    }
    if (!names.add(name.trim().toLowerCase())) {
      return true;
    }
  }
  return false;
}

LocalDataExportSnapshot _withRestoredMediaUris(
  LocalDataExportSnapshot snapshot,
  Map<String, RestoredMediaFile> restoredFiles,
) {
  if (restoredFiles.isEmpty) {
    return snapshot;
  }
  final mediaAssets = [
    for (final asset in snapshot.mediaAssets)
      if (restoredFiles[asset.id] case final restored?)
        asset.copyWith(
          payload: {
            ...asset.payload,
            'localUri': restored.localUri,
            'sizeBytes': restored.sizeBytes,
          },
        )
      else
        asset,
  ];
  return snapshot.copyWith(mediaAssets: mediaAssets);
}

String _localBackupFileName(DateTime exportedAt) {
  final utc = exportedAt.toUtc();
  final year = utc.year.toString().padLeft(4, '0');
  final month = utc.month.toString().padLeft(2, '0');
  final day = utc.day.toString().padLeft(2, '0');
  return 'gametime-sauvegarde-$year-$month-$day.gametime';
}
