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

import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';

void main() {
  test('Exercise requires at least one active measure', () {
    expect(
      () => Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Push up',
        hasTimeMeasure: false,
        hasRepsMeasure: false,
        hasScoreMeasure: false,
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test(
    'Exercise use case rejects active measures without default targets',
    () async {
      final useCase = _exerciseUseCase(_FakeExerciseRepository());

      await expectLater(
        useCase.create(
          name: 'Squat',
          hasTimeMeasure: true,
          hasRepsMeasure: false,
          hasScoreMeasure: false,
        ),
        throwsA(isA<DomainException>()),
      );
      await expectLater(
        useCase.create(
          name: 'Pompes',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
        ),
        throwsA(isA<DomainException>()),
      );
      await expectLater(
        useCase.create(
          name: 'Charge',
          hasTimeMeasure: false,
          hasRepsMeasure: false,
          hasScoreMeasure: true,
          scoreLabel: 'Charge',
          scoreUnit: 'kg',
        ),
        throwsA(isA<DomainException>()),
      );
    },
  );

  test('Exercise use case rejects invalid default targets', () async {
    final useCase = _exerciseUseCase(_FakeExerciseRepository());

    await expectLater(
      useCase.create(
        name: 'Squat',
        hasTimeMeasure: true,
        hasRepsMeasure: false,
        hasScoreMeasure: false,
        defaultTargetTimeSeconds: 0,
      ),
      throwsA(isA<DomainException>()),
    );
    await expectLater(
      useCase.create(
        name: 'Pompes',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: -1,
      ),
      throwsA(isA<DomainException>()),
    );
    await expectLater(
      useCase.create(
        name: 'Charge',
        hasTimeMeasure: false,
        hasRepsMeasure: false,
        hasScoreMeasure: true,
        scoreLabel: 'Charge',
        scoreUnit: 'kg',
        defaultTargetScore: -1,
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test(
    'Exercise use case accepts a zero manual score default target',
    () async {
      final repository = _FakeExerciseRepository();
      final useCase = _exerciseUseCase(repository);

      final exercise = await useCase.create(
        name: 'Charge',
        hasTimeMeasure: false,
        hasRepsMeasure: false,
        hasScoreMeasure: true,
        scoreLabel: 'Charge',
        scoreUnit: 'kg',
        defaultTargetScore: 0,
      );

      expect(exercise.defaultTargetScore, 0);
      expect(repository.saved.single.defaultTargetScore, 0);
    },
  );

  test(
    'Exercise use case accepts stopwatch score without default target',
    () async {
      final repository = _FakeExerciseRepository();
      final useCase = _exerciseUseCase(repository);

      final exercise = await useCase.create(
        name: 'Sprint',
        hasTimeMeasure: false,
        hasRepsMeasure: false,
        hasScoreMeasure: true,
        scoreInputMode: ScoreInputMode.stopwatch,
        scoreLabel: 'Temps réalisé',
      );

      expect(exercise.defaultTargetScoreTimeMs, isNull);
      expect(repository.saved.single.metadata.id, exercise.metadata.id);
    },
  );

  test(
    'Exercise use case rejects non-positive stopwatch score default target',
    () async {
      final useCase = _exerciseUseCase(_FakeExerciseRepository());

      await expectLater(
        useCase.create(
          name: 'Sprint',
          hasTimeMeasure: false,
          hasRepsMeasure: false,
          hasScoreMeasure: true,
          scoreInputMode: ScoreInputMode.stopwatch,
          scoreLabel: 'Temps réalisé',
          defaultTargetScoreTimeMs: 0,
        ),
        throwsA(isA<DomainException>()),
      );
    },
  );

  test('Exercise update validates default targets too', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    await expectLater(
      useCase.update(
        id: 'exercise-1',
        name: 'Squat',
        hasTimeMeasure: true,
        hasRepsMeasure: false,
        hasScoreMeasure: false,
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test('Exercise use case rejects more than eight steps', () async {
    final useCase = _exerciseUseCase(_FakeExerciseRepository());

    await expectLater(
      useCase.create(
        name: 'Complexe',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 1,
        steps: [
          for (var index = 0; index < 9; index++)
            _step(id: 'step-$index', position: index),
        ],
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test('Exercise use case rejects non-contiguous step positions', () async {
    final useCase = _exerciseUseCase(_FakeExerciseRepository());

    await expectLater(
      useCase.create(
        name: 'Complexe',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 1,
        steps: [
          _step(id: 'step-1', position: 1),
          _step(id: 'step-0', position: 0),
        ],
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test('Exercise step validation rejects invalid basic fields', () {
    expect(() => _step(name: ''), throwsA(isA<DomainException>()));
    expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
  });

  test('Exercise step validation rejects disabled score values', () {
    expect(
      () => _step(hasScore: false, scoreLabel: 'Charge'),
      throwsA(isA<DomainException>()),
    );
    expect(
      () => _step(hasScore: false, defaultTargetScore: 0),
      throwsA(isA<DomainException>()),
    );
  });

  test(
    'Exercise step manual score validation accepts zero and rejects negative',
    () {
      final step = _step(
        hasScore: true,
        scoreLabel: 'Charge',
        scoreUnit: 'kg',
        defaultTargetScore: 0,
      );

      expect(step.defaultTargetScore, 0);
      expect(
        () => _step(
          hasScore: true,
          scoreLabel: 'Charge',
          scoreUnit: 'kg',
          defaultTargetScore: -1,
        ),
        throwsA(isA<DomainException>()),
      );
      expect(
        () => _step(hasScore: true, scoreLabel: 'Charge'),
        throwsA(isA<DomainException>()),
      );
    },
  );

  test('Exercise step stopwatch score validation rejects manual fields', () {
    final step = _step(
      hasScore: true,
      scoreInputMode: ScoreInputMode.stopwatch,
      defaultTargetScoreTimeMs: 12000,
    );

    expect(step.defaultTargetScoreTimeMs, 12000);
    expect(
      () => _step(
        hasScore: true,
        scoreInputMode: ScoreInputMode.stopwatch,
        defaultTargetScoreTimeMs: 0,
      ),
      throwsA(isA<DomainException>()),
    );
    expect(
      () => _step(
        hasScore: true,
        scoreInputMode: ScoreInputMode.stopwatch,
        scoreLabel: 'Charge',
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test('Exercise image gallery rejects a sixth image', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const [
          'media-1',
          'media-2',
          'media-3',
          'media-4',
          'media-5',
        ],
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    await expectLater(
      useCase.addImage(exerciseId: 'exercise-1', mediaAssetId: 'media-6'),
      throwsA(isA<DomainException>()),
    );
  });

  test('Exercise image gallery reorders images', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const ['media-1', 'media-2', 'media-3'],
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    final updated = await useCase.reorderImages(
      exerciseId: 'exercise-1',
      orderedMediaIds: const ['media-3', 'media-1', 'media-2'],
    );

    expect(updated.imageMediaIds, ['media-3', 'media-1', 'media-2']);
    expect(repository.saved.single.imageMediaIds, updated.imageMediaIds);
  });

  test('Exercise image gallery removes an image', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const ['media-1', 'media-2', 'media-3'],
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    final updated = await useCase.removeImage(
      exerciseId: 'exercise-1',
      mediaAssetId: 'media-2',
    );

    expect(updated.imageMediaIds, ['media-1', 'media-3']);
    expect(updated.imageMediaId, 'media-1');
  });

  test('Exercise icon can be set to an image from the gallery', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const ['media-1', 'media-2'],
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    final updated = await useCase.setIcon(
      exerciseId: 'exercise-1',
      mediaAssetId: 'media-2',
    );

    expect(updated.iconMediaId, 'media-2');
    expect(repository.saved.single.iconMediaId, 'media-2');
  });

  test('Exercise icon rejects images outside the gallery', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const ['media-1'],
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    await expectLater(
      useCase.setIcon(exerciseId: 'exercise-1', mediaAssetId: 'media-2'),
      throwsA(isA<DomainException>()),
    );
    expect(repository.saved, isEmpty);
  });

  test('Exercise icon is reset when its image is removed', () async {
    final repository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const ['media-1', 'media-2'],
        iconMediaId: 'media-2',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final useCase = _exerciseUseCase(repository);

    final updated = await useCase.removeImage(
      exerciseId: 'exercise-1',
      mediaAssetId: 'media-2',
    );

    expect(updated.imageMediaIds, ['media-1']);
    expect(updated.iconMediaId, isNull);
    expect(repository.saved.single.iconMediaId, isNull);
  });

  test('program exercise snapshot keeps the full image gallery', () async {
    final exerciseRepository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        imageMediaIds: const ['media-1', 'media-2', 'media-3'],
        videoMediaId: 'video-1',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final programRepository = _FakeProgramRepository()
      ..programs.add(
        Program(
          metadata: _metadata('program-1'),
          name: 'Jambes',
          defaultRestSeconds: 60,
        ),
      );
    final useCase = ProgramUseCases(
      programRepository: programRepository,
      exerciseRepository: exerciseRepository,
      templateRepository: _FakeWorkoutTemplateRepository(),
      clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    final snapshot = await useCase.addExercise(
      programId: 'program-1',
      exerciseId: 'exercise-1',
      position: 0,
      setsCount: 3,
      enabledMeasures: const {WorkoutMeasure.reps},
    );

    expect(snapshot.exerciseImageMediaIdSnapshot, 'media-1');
    expect(snapshot.exerciseImageMediaIdsSnapshot, [
      'media-1',
      'media-2',
      'media-3',
    ]);
    expect(snapshot.exerciseVideoMediaIdSnapshot, 'video-1');
    expect(snapshot.toSnapshotJson()['imageMediaIdsSnapshot'], [
      'media-1',
      'media-2',
      'media-3',
    ]);
    expect(
      programRepository
          .programs
          .single
          .exercises
          .single
          .exerciseImageMediaIdsSnapshot,
      snapshot.exerciseImageMediaIdsSnapshot,
    );
  });

  test(
    'program exercise snapshots step chaining setting from exercise',
    () async {
      final exerciseRepository = _FakeExerciseRepository()
        ..exercise = Exercise(
          metadata: _metadata('exercise-1'),
          name: 'Circuit',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
          defaultTargetReps: 10,
          autoStartNextTimedStep: false,
        );
      final programRepository = _FakeProgramRepository()
        ..programs.add(
          Program(
            metadata: _metadata('program-1'),
            name: 'Programme',
            defaultRestSeconds: 60,
          ),
        );
      final useCase = ProgramUseCases(
        programRepository: programRepository,
        exerciseRepository: exerciseRepository,
        templateRepository: _FakeWorkoutTemplateRepository(),
        clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final snapshot = await useCase.addExercise(
        programId: 'program-1',
        exerciseId: 'exercise-1',
        position: 0,
        setsCount: 3,
        enabledMeasures: const {WorkoutMeasure.reps},
      );

      expect(snapshot.autoStartNextTimedStepSnapshot, isFalse);
      expect(snapshot.autoStartNextTimedStepOverride, isNull);
      expect(
        snapshot.toSnapshotJson()['autoStartNextTimedStepSnapshot'],
        isFalse,
      );
    },
  );

  test('program saveConfigured preserves step chaining override', () async {
    final programRepository = _FakeProgramRepository();
    final useCase = ProgramUseCases(
      programRepository: programRepository,
      exerciseRepository: _FakeExerciseRepository(),
      templateRepository: _FakeWorkoutTemplateRepository(),
      clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    final program = await useCase.saveConfigured(
      name: 'Programme',
      defaultRestSeconds: 60,
      exercises: const [
        ProgramExerciseConfig(
          exerciseNameSnapshot: 'Circuit',
          availableTimeSnapshot: false,
          availableRepsSnapshot: true,
          availableScoreSnapshot: false,
          setsCount: 3,
          enabledMeasures: {WorkoutMeasure.reps},
          targetReps: 10,
          autoStartNextTimedStepSnapshot: true,
          autoStartNextTimedStepOverride: false,
        ),
      ],
    );

    expect(program.exercises.single.autoStartNextTimedStepSnapshot, isTrue);
    expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse);
  });

  test('workout template override preserves step chaining override', () async {
    final templateRepository = _FakeWorkoutTemplateRepository();
    final useCase = WorkoutTemplateUseCases(
      templateRepository: templateRepository,
      programRepository: _FakeProgramRepository(),
      clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    final template = await useCase.saveConfigured(
      name: 'Séance',
      programs: [
        WorkoutTemplateProgramConfig(
          clientKey: 'program-1',
          programNameSnapshot: 'Programme',
          defaultRestSecondsSnapshot: 60,
          programSnapshotJson: jsonEncode({
            'exercises': [
              {
                'id': 'exercise-snapshot-1',
                'exerciseNameSnapshot': 'Circuit',
                'setsCount': 3,
                'timeEnabled': false,
                'repsEnabled': true,
                'scoreEnabled': false,
              },
            ],
          }),
        ),
      ],
      overrides: const [
        WorkoutTemplateExerciseOverrideConfig(
          workoutTemplateProgramClientKey: 'program-1',
          snapshotProgramExerciseId: 'exercise-snapshot-1',
          autoStartNextTimedStepOverride: false,
        ),
      ],
    );

    expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
  });

  test('delete exercise keeps existing program snapshots unchanged', () async {
    final exerciseRepository = _FakeExerciseRepository()
      ..exercise = Exercise(
        metadata: _metadata('exercise-1'),
        name: 'Squat',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      );
    final programRepository = _FakeProgramRepository()
      ..programs.add(
        Program(
          metadata: _metadata('program-1'),
          name: 'Jambes',
          defaultRestSeconds: 60,
          exercises: [
            _programExercise(
              id: 'program-exercise-1',
              programId: 'program-1',
              sourceExerciseId: 'exercise-1',
              position: 0,
            ),
          ],
        ),
      );
    final useCase = _exerciseUseCase(
      exerciseRepository,
      programRepository: programRepository,
    );

    final deleted = await useCase.delete('exercise-1');

    expect(deleted.metadata.deletedAt, isNotNull);
    expect(exerciseRepository.listActive(), completion(isEmpty));
    expect(programRepository.programs.single.exercises, hasLength(1));
    expect(
      programRepository.programs.single.exercises.single.sourceExerciseId,
      'exercise-1',
    );
    expect(
      programRepository.programs.single.exercises.single.exerciseNameSnapshot,
      'Exercise program-exercise-1',
    );
    expect(programRepository.programs.single.exercises.single.position, 0);
  });

  test(
    'delete program removes it from referencing workout templates',
    () async {
      final programRepository = _FakeProgramRepository()
        ..programs.add(
          Program(
            metadata: _metadata('program-1'),
            name: 'Jambes',
            defaultRestSeconds: 60,
          ),
        );
      final templateRepository = _FakeWorkoutTemplateRepository()
        ..templates.add(
          WorkoutTemplate(
            metadata: _metadata('template-1'),
            name: 'Séance',
            programs: [
              _templateProgram(
                id: 'template-program-1',
                workoutTemplateId: 'template-1',
                sourceProgramId: 'program-1',
                position: 0,
              ),
              _templateProgram(
                id: 'template-program-2',
                workoutTemplateId: 'template-1',
                sourceProgramId: 'program-2',
                position: 1,
              ),
            ],
            overrides: [
              WorkoutTemplateExerciseOverride(
                metadata: _metadata('override-1'),
                workoutTemplateProgramId: 'template-program-1',
                snapshotProgramExerciseId: 'exercise-snapshot-1',
                targetRepsOverride: 8,
              ),
            ],
          ),
        );
      final useCase = ProgramUseCases(
        programRepository: programRepository,
        exerciseRepository: _FakeExerciseRepository(),
        templateRepository: templateRepository,
        clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final deleted = await useCase.delete('program-1');

      expect(deleted.metadata.deletedAt, isNotNull);
      expect(programRepository.listActive(), completion(isEmpty));
      final template = templateRepository.templates.single;
      expect(template.programs, hasLength(1));
      expect(template.programs.single.sourceProgramId, 'program-2');
      expect(template.programs.single.position, 0);
      expect(template.overrides, isEmpty);
    },
  );

  test('delete workout template hides it from active list', () async {
    final templateRepository = _FakeWorkoutTemplateRepository()
      ..templates.add(
        WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
      );
    final useCase = WorkoutTemplateUseCases(
      templateRepository: templateRepository,
      programRepository: _FakeProgramRepository(),
      clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    final deleted = await useCase.delete('template-1');

    expect(deleted.metadata.deletedAt, isNotNull);
    expect(templateRepository.listActive(), completion(isEmpty));
  });

  test('history stays unchanged after deleting its source template', () async {
    final templateRepository = _FakeWorkoutTemplateRepository()
      ..templates.add(
        WorkoutTemplate(metadata: _metadata('template-1'), name: 'Séance'),
      );
    final history = WorkoutHistory(
      metadata: _metadata('history-1'),
      sourceWorkoutTemplateId: 'template-1',
      nameSnapshot: 'Séance historique',
      startedAt: DateTime.utc(2026, 7, 17, 10),
      endedAt: DateTime.utc(2026, 7, 17, 11),
      totalActiveMs: 3600000,
      completed: true,
      historySnapshotJson: '{"name":"Séance historique"}',
    );
    final useCase = WorkoutTemplateUseCases(
      templateRepository: templateRepository,
      programRepository: _FakeProgramRepository(),
      clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    await useCase.delete('template-1');

    expect(history.sourceWorkoutTemplateId, 'template-1');
    expect(history.nameSnapshot, 'Séance historique');
    expect(history.historySnapshotJson, '{"name":"Séance historique"}');
  });

  test('Workout template use case rejects active measure overrides', () async {
    final useCase = WorkoutTemplateUseCases(
      templateRepository: _FakeWorkoutTemplateRepository(),
      programRepository: _FakeProgramRepository(),
      clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    await expectLater(
      useCase.overrideExerciseTargets(
        workoutTemplateProgramId: 'template-program-1',
        snapshotProgramExerciseId: 'exercise-snapshot-1',
        attemptedMeasureOverride: {WorkoutMeasure.reps},
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test(
    'Workout template use case rejects target overrides for inactive measures',
    () async {
      final useCase = WorkoutTemplateUseCases(
        templateRepository: _FakeWorkoutTemplateRepository(),
        programRepository: _FakeProgramRepository(),
        clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await expectLater(
        useCase.saveConfigured(
          name: 'Séance tir',
          programs: [
            WorkoutTemplateProgramConfig(
              clientKey: 'program-1',
              programNameSnapshot: 'Programme tir',
              defaultRestSecondsSnapshot: 30,
              programSnapshotJson: jsonEncode({
                'exercises': [
                  {
                    'id': 'exercise-snapshot-1',
                    'exerciseNameSnapshot': 'Lancers francs',
                    'setsCount': 3,
                    'timeEnabled': true,
                    'repsEnabled': false,
                    'scoreEnabled': false,
                    'targetTimeSeconds': 60,
                    'targetReps': null,
                    'targetScore': null,
                  },
                ],
              }),
            ),
          ],
          overrides: const [
            WorkoutTemplateExerciseOverrideConfig(
              workoutTemplateProgramClientKey: 'program-1',
              snapshotProgramExerciseId: 'exercise-snapshot-1',
              targetRepsOverride: 10,
            ),
          ],
        ),
        throwsA(isA<DomainException>()),
      );
    },
  );

  test('Active session elapsed time is based on persisted timestamps', () {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final session = ActiveWorkoutSession(
      metadata: _metadata('session-1'),
      status: ActiveWorkoutStatus.running,
      startedAt: clock.now(),
      lastPersistedAt: clock.now(),
      elapsedActiveMs: 0,
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
      resolvedTemplateSnapshotJson: '{}',
    );

    clock.value = clock.value.add(const Duration(seconds: 30));
    final paused = session.pause(clock.now());
    expect(paused.elapsedActiveMs, 30000);

    clock.value = clock.value.add(const Duration(minutes: 5));
    final resumed = paused.resume(clock.now());
    expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 30000);

    clock.value = clock.value.add(const Duration(seconds: 15));
    expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
  });

  test('adjustRestSeconds persists adjusted rest duration', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final repository = _FakeActiveSessionRepository()
      ..restStates['rest-1'] = ActiveRestState(
        metadata: _metadata('rest-1'),
        activeWorkoutSessionId: 'session-1',
        afterProgramIndex: 0,
        afterExerciseIndex: 0,
        afterSetIndex: 0,
        plannedRestSeconds: 60,
        adjustedRestSeconds: 60,
        startedAt: clock.now(),
      );
    final useCase = _activeUseCase(repository, clock);

    final adjusted = await useCase.adjustRestSeconds(
      restStateId: 'rest-1',
      deltaSeconds: -75,
    );

    expect(adjusted.adjustedRestSeconds, 0);
    expect(repository.restStates['rest-1']!.adjustedRestSeconds, 0);
  });

  test('skipRest persists skippedAt timestamp', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final repository = _FakeActiveSessionRepository()
      ..restStates['rest-1'] = ActiveRestState(
        metadata: _metadata('rest-1'),
        activeWorkoutSessionId: 'session-1',
        afterProgramIndex: 0,
        afterExerciseIndex: 0,
        afterSetIndex: 0,
        plannedRestSeconds: 60,
        adjustedRestSeconds: 60,
        startedAt: clock.now(),
      );
    clock.value = clock.value.add(const Duration(seconds: 10));
    final useCase = _activeUseCase(repository, clock);

    final skipped = await useCase.skipRest(restStateId: 'rest-1');

    expect(skipped.skippedAt, clock.now());
    expect(repository.restStates['rest-1']!.skippedAt, clock.now());
  });

  test('findActiveRest returns latest non-ended non-skipped rest', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final repository = _FakeActiveSessionRepository()
      ..restStates['ended'] = ActiveRestState(
        metadata: _metadata('ended'),
        activeWorkoutSessionId: 'session-1',
        afterProgramIndex: 0,
        afterExerciseIndex: 0,
        afterSetIndex: 0,
        plannedRestSeconds: 60,
        adjustedRestSeconds: 60,
        startedAt: clock.now(),
        endedAt: clock.now().add(const Duration(seconds: 60)),
      )
      ..restStates['older-active'] = ActiveRestState(
        metadata: _metadata('older-active'),
        activeWorkoutSessionId: 'session-1',
        afterProgramIndex: 0,
        afterExerciseIndex: 1,
        afterSetIndex: 0,
        plannedRestSeconds: 60,
        adjustedRestSeconds: 60,
        startedAt: clock.now().add(const Duration(seconds: 30)),
      )
      ..restStates['latest-active'] = ActiveRestState(
        metadata: _metadata('latest-active'),
        activeWorkoutSessionId: 'session-1',
        afterProgramIndex: 0,
        afterExerciseIndex: 2,
        afterSetIndex: 0,
        plannedRestSeconds: 60,
        adjustedRestSeconds: 60,
        startedAt: clock.now().add(const Duration(seconds: 90)),
      )
      ..restStates['skipped'] = ActiveRestState(
        metadata: _metadata('skipped'),
        activeWorkoutSessionId: 'session-1',
        afterProgramIndex: 0,
        afterExerciseIndex: 3,
        afterSetIndex: 0,
        plannedRestSeconds: 60,
        adjustedRestSeconds: 60,
        startedAt: clock.now().add(const Duration(seconds: 120)),
        skippedAt: clock.now().add(const Duration(seconds: 125)),
      );
    final useCase = _activeUseCase(repository, clock);

    final active = await useCase.findActiveRest(sessionId: 'session-1');

    expect(active?.metadata.id, 'latest-active');
  });

  test(
    'startSetExecution starts set timer score stopwatch and first timed step',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        timeEnabled: true,
        repsEnabled: false,
        scoreEnabled: true,
        scoreInputMode: ScoreInputMode.stopwatch,
        exerciseSteps: [_step(type: ExerciseStepType.time)],
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = _activeUseCase(repository, clock);

      final started = await useCase.startSetExecution(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(started.setTimer?.status, ActiveSetTimerStatus.running);
      expect(
        started.scoreStopwatch?.status,
        ActiveScoreStopwatchStatus.running,
      );
      expect(
        started.stepProgress?.status,
        ActiveExerciseStepProgressStatus.runningTimer,
      );
      expect(repository.setTimerStates.length, 1);
      expect(repository.scoreStopwatchStates.length, 1);
      expect(repository.stepProgressStates.length, 1);
    },
  );

  test('startSetExecution is idempotent for already running timers', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final session = _sessionWithSnapshot(
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
      timeEnabled: true,
      repsEnabled: false,
      scoreEnabled: true,
      scoreInputMode: ScoreInputMode.stopwatch,
      exerciseSteps: [_step(type: ExerciseStepType.time)],
    );
    final repository = _FakeActiveSessionRepository()..session = session;
    final useCase = _activeUseCase(repository, clock);

    final first = await useCase.startSetExecution(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );
    clock.value = clock.value.add(const Duration(seconds: 3));
    final second = await useCase.startSetExecution(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );

    expect(second.setTimer?.metadata.id, first.setTimer?.metadata.id);
    expect(second.setTimer?.startedAt, first.setTimer?.startedAt);
    expect(repository.setTimerStates.length, 1);
    expect(repository.scoreStopwatchStates.length, 1);
    expect(repository.stepProgressStates.length, 1);
  });

  test(
    'startSetExecution does not start a next timed step left stopped',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        timeEnabled: true,
        repsEnabled: false,
        exerciseSteps: _twoTimedSteps(),
      );
      final repository = _FakeActiveSessionRepository()
        ..session = session
        ..stepProgressStates['step-state'] = ActiveExerciseStepProgressState(
          metadata: _metadata('step-state'),
          activeWorkoutSessionId: session.metadata.id,
          programIndex: 0,
          exerciseIndex: 0,
          setIndex: 0,
          currentPassageIndex: 0,
          currentStepIndex: 1,
          currentStepSnapshotId: 'step-2',
          status: ActiveExerciseStepProgressStatus.stoppedTimer,
          accumulatedMs: 0,
          lastTransitionAt: clock.now(),
        );
      final useCase = _activeUseCase(repository, clock);

      final started = await useCase.startSetExecution(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(
        started.stepProgress?.status,
        ActiveExerciseStepProgressStatus.stoppedTimer,
      );
      expect(started.stepProgress?.currentStepIndex, 1);
      expect(started.stepProgress?.startedAt, isNull);
    },
  );

  test('pause and resume restore running persistent timers', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final session = _sessionWithSnapshot(
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
      timeEnabled: true,
      repsEnabled: false,
      scoreEnabled: true,
      scoreInputMode: ScoreInputMode.stopwatch,
      exerciseSteps: [_step(type: ExerciseStepType.time)],
    );
    final repository = _FakeActiveSessionRepository()..session = session;
    final useCase = _activeUseCase(repository, clock);

    await useCase.startSetExecution(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );
    await useCase.startRestAfterSet(
      sessionId: session.metadata.id,
      afterProgramIndex: 0,
      afterExerciseIndex: 0,
      afterSetIndex: 0,
      plannedRestSeconds: 60,
    );
    clock.value = clock.value.add(const Duration(seconds: 5));
    await useCase.pause(session.metadata.id);

    expect(
      repository.setTimerStates.values.single.status,
      ActiveSetTimerStatus.paused,
    );
    expect(repository.setTimerStates.values.single.accumulatedMs, 5000);
    expect(
      repository.scoreStopwatchStates.values.single.status,
      ActiveScoreStopwatchStatus.paused,
    );
    expect(
      repository.stepProgressStates.values.single.status,
      ActiveExerciseStepProgressStatus.pausedTimer,
    );
    final pausedRest = repository.restStates.values.single;
    expect(pausedRest.pausedAt, clock.now());
    expect(pausedRest.remainingMillisecondsAt(clock.now()), 55000);

    clock.value = clock.value.add(const Duration(minutes: 2));
    await useCase.resume(session.metadata.id);

    expect(
      repository.setTimerStates.values.single.status,
      ActiveSetTimerStatus.running,
    );
    expect(
      repository.scoreStopwatchStates.values.single.status,
      ActiveScoreStopwatchStatus.running,
    );
    expect(
      repository.stepProgressStates.values.single.status,
      ActiveExerciseStepProgressStatus.runningTimer,
    );
    final resumedRest = repository.restStates.values.single;
    expect(resumedRest.pausedAt, isNull);
    expect(resumedRest.accumulatedPausedMs, 120000);
    expect(resumedRest.remainingMillisecondsAt(clock.now()), 55000);
  });

  test('upsertSetResultAtPosition does not move session cursor', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final session = _sessionWithSnapshot(
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 1,
    );
    final repository = _FakeActiveSessionRepository()..session = session;
    final useCase = _activeUseCase(repository, clock);

    await useCase.upsertSetResultAtPosition(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
      status: SetResultStatus.completed,
      actualReps: 12,
    );

    expect(repository.session?.currentProgramIndex, 0);
    expect(repository.session?.currentExerciseIndex, 0);
    expect(repository.session?.currentSetIndex, 1);
    expect(repository.results.single.actualReps, 12);
  });

  test(
    'upsertSetResultAtPosition preserves existing id and createdAt',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 1,
      );
      final existingCreatedAt = DateTime.utc(2026, 7, 17, 10);
      final existing = ActiveSetResult(
        metadata: EntityMetadata(
          id: 'result-1',
          createdAt: existingCreatedAt,
          updatedAt: existingCreatedAt,
          originDeviceId: 'device-1',
        ),
        activeWorkoutSessionId: session.metadata.id,
        programSnapshotId: 'program-snapshot-1',
        exerciseSnapshotId: 'exercise-snapshot-1',
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        actualReps: 8,
      );
      final repository = _FakeActiveSessionRepository()
        ..session = session
        ..results.add(existing);
      final useCase = _activeUseCase(repository, clock);

      final updated = await useCase.upsertSetResultAtPosition(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        status: SetResultStatus.completed,
        actualReps: 12,
      );

      expect(updated.metadata.id, 'result-1');
      expect(updated.metadata.createdAt, existingCreatedAt);
      expect(updated.actualReps, 12);
    },
  );

  test(
    'listSetResults returns pending, skipped and completed statuses',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 2,
        setsCount: 3,
      );
      final repository = _FakeActiveSessionRepository()
        ..session = session
        ..results.addAll([
          ActiveSetResult(
            metadata: _metadata('completed'),
            activeWorkoutSessionId: session.metadata.id,
            programSnapshotId: 'program-snapshot-1',
            exerciseSnapshotId: 'exercise-snapshot-1',
            programIndex: 0,
            exerciseIndex: 0,
            setIndex: 0,
            actualReps: 10,
            status: SetResultStatus.completed,
          ),
          ActiveSetResult(
            metadata: _metadata('skipped'),
            activeWorkoutSessionId: session.metadata.id,
            programSnapshotId: 'program-snapshot-1',
            exerciseSnapshotId: 'exercise-snapshot-1',
            programIndex: 0,
            exerciseIndex: 0,
            setIndex: 1,
            status: SetResultStatus.skipped,
          ),
        ]);
      final useCase = _activeUseCase(repository, clock);

      final states = await useCase.listSetResults(session.metadata.id);

      expect(states.map((state) => state.status), [
        SetPositionStatus.completed,
        SetPositionStatus.skipped,
        SetPositionStatus.pending,
      ]);
    },
  );

  test(
    'score stopwatch start stop resume accumulates elapsed duration',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = _activeUseCase(repository, clock);

      await useCase.startScoreStopwatch(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(seconds: 5));
      final stopped = await useCase.stopScoreStopwatch(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(seconds: 10));
      await useCase.resumeScoreStopwatch(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(seconds: 3));
      final stoppedAgain = await useCase.stopScoreStopwatch(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(stopped.accumulatedMs, 5000);
      expect(stoppedAgain.accumulatedMs, 8000);
    },
  );

  test('score stopwatch state survives use case reconstruction', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final session = _sessionWithSnapshot(
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
    );
    final repository = _FakeActiveSessionRepository()..session = session;

    await _activeUseCase(repository, clock).startScoreStopwatch(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );
    clock.value = clock.value.add(const Duration(seconds: 4));

    final reconstructed = _activeUseCase(repository, clock);
    final stopped = await reconstructed.stopScoreStopwatch(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );

    expect(stopped.accumulatedMs, 4000);
  });

  test(
    'exercise step progress auto-advances consecutive elapsed timers after kill',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 2,
        exerciseSteps: [
          _step(
            id: 'step-1',
            position: 0,
            name: 'Gauche',
            type: ExerciseStepType.time,
            defaultTargetValue: 1,
          ),
          _step(
            id: 'step-2',
            position: 1,
            name: 'Droite',
            type: ExerciseStepType.time,
            defaultTargetValue: 1,
          ),
        ],
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await useCase.startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      clock.value = clock.value.add(const Duration(milliseconds: 4500));
      final reconstructed = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final view = await reconstructed.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(
        view.state.status,
        ActiveExerciseStepProgressStatus.sequenceComplete,
      );
      expect(repository.stepResults, hasLength(4));
      expect(repository.stepResults.map((result) => result.passageIndex), [
        0,
        0,
        1,
        1,
      ]);
      expect(repository.stepResults.map((result) => result.stepIndex), [
        0,
        1,
        0,
        1,
      ]);
      expect(repository.stepResults.map((result) => result.actualTimeMs), [
        1000,
        1000,
        1000,
        1000,
      ]);
    },
  );

  test(
    'step chaining resolution uses template override before program override',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 1,
        autoStartNextTimedStepSnapshot: true,
        autoStartNextTimedStepOverride: true,
        templateAutoStartNextTimedStepOverride: false,
        exerciseSteps: _twoTimedSteps(),
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await useCase.startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(milliseconds: 2500));

      final view = await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
      expect(view.state.currentStepIndex, 1);
      expect(view.state.accumulatedMs, 0);
      expect(repository.stepResults, hasLength(1));
    },
  );

  test(
    'step chaining disabled stops at first elapsed timed boundary after kill',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 1,
        autoStartNextTimedStepSnapshot: false,
        exerciseSteps: [
          ..._twoTimedSteps(),
          _step(
            id: 'step-3',
            position: 2,
            name: 'Centre',
            type: ExerciseStepType.time,
            defaultTargetValue: 1,
          ),
        ],
      );
      final repository = _FakeActiveSessionRepository()..session = session;

      await ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      ).startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      ).startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(milliseconds: 4500));

      final reconstructed = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );
      final view = await reconstructed.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
      expect(view.state.currentStepIndex, 1);
      expect(view.state.startedAt, isNull);
      expect(view.state.accumulatedMs, 0);
      expect(repository.stepResults, hasLength(1));
      expect(repository.stepResults.single.stepIndex, 0);
    },
  );

  test(
    'step chaining resolution uses program override before exercise snapshot',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 1,
        autoStartNextTimedStepSnapshot: false,
        autoStartNextTimedStepOverride: true,
        exerciseSteps: _twoTimedSteps(),
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await useCase.startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(milliseconds: 2500));

      final view = await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(
        view.state.status,
        ActiveExerciseStepProgressStatus.sequenceComplete,
      );
      expect(repository.stepResults, hasLength(2));
    },
  );

  test(
    'step chaining defaults to true when snapshot fields are absent',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 1,
        autoStartNextTimedStepSnapshot: null,
        exerciseSteps: _twoTimedSteps(),
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await useCase.startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(milliseconds: 2500));

      final view = await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(
        view.state.status,
        ActiveExerciseStepProgressStatus.sequenceComplete,
      );
      expect(repository.stepResults, hasLength(2));
    },
  );

  test(
    'completeCurrentStep leaves next timed step stopped when chaining disabled',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 1,
        autoStartNextTimedStepSnapshot: false,
        exerciseSteps: _twoTimedSteps(),
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await useCase.startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(milliseconds: 500));
      await useCase.completeCurrentStep(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      final view = await useCase.readProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(view.state.status, ActiveExerciseStepProgressStatus.stoppedTimer);
      expect(view.state.currentStepIndex, 1);
      expect(view.state.startedAt, isNull);
      expect(view.state.accumulatedMs, 0);
    },
  );

  test(
    'completeCurrentStep starts next timed step when chaining is enabled',
    () async {
      final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
      final session = _sessionWithSnapshot(
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        setsCount: 1,
        targetReps: 1,
        autoStartNextTimedStepSnapshot: true,
        exerciseSteps: _twoTimedSteps(),
      );
      final repository = _FakeActiveSessionRepository()..session = session;
      final useCase = ActiveExerciseStepUseCases(
        sessionRepository: repository,
        clock: clock,
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      await useCase.startOrResumeProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      await useCase.startTimer(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );
      clock.value = clock.value.add(const Duration(milliseconds: 1500));
      await useCase.completeCurrentStep(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      final view = await useCase.readProgress(
        sessionId: session.metadata.id,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
      );

      expect(view.state.status, ActiveExerciseStepProgressStatus.runningTimer);
      expect(view.state.currentStepIndex, 1);
      expect(view.state.elapsedMillisecondsAt(clock.now()), 500);
    },
  );

  test('startTimer manually starts a stopped next timed step', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final session = _sessionWithSnapshot(
      currentProgramIndex: 0,
      currentExerciseIndex: 0,
      currentSetIndex: 0,
      setsCount: 1,
      targetReps: 1,
      autoStartNextTimedStepSnapshot: false,
      exerciseSteps: _twoTimedSteps(),
    );
    final repository = _FakeActiveSessionRepository()..session = session;
    final useCase = ActiveExerciseStepUseCases(
      sessionRepository: repository,
      clock: clock,
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    await useCase.startOrResumeProgress(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );
    await useCase.startTimer(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );
    clock.value = clock.value.add(const Duration(milliseconds: 1500));
    await useCase.startOrResumeProgress(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );

    final running = await useCase.startTimer(
      sessionId: session.metadata.id,
      programIndex: 0,
      exerciseIndex: 0,
      setIndex: 0,
    );

    expect(running.status, ActiveExerciseStepProgressStatus.runningTimer);
    expect(running.currentStepIndex, 1);
    expect(running.startedAt, clock.now());
  });

  test('AuthUseCases register stores token and account session', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final tokenStore = _FakeAuthTokenStore();
    final accountRepository = _FakeOnlineAccountRepository();
    final remoteAuthApi = _FakeRemoteAuthApi();
    final useCase = _authUseCase(
      tokenStore: tokenStore,
      accountRepository: accountRepository,
      remoteAuthApi: remoteAuthApi,
      clock: clock,
    );

    final session = await useCase.register(
      email: 'USER@example.com',
      password: 'password123',
      displayName: 'User',
    );

    expect(session.serverUserId, 'server-user-1');
    expect(session.email, 'user@example.com');
    expect(session.displayName, 'User');
    expect(session.isLoggedIn, isTrue);
    expect(tokenStore.token, 'token-1');
    expect(accountRepository.session, session);
  });

  test('AuthUseCases register propagates email already used error', () async {
    final remoteAuthApi = _FakeRemoteAuthApi()
      ..registerException = const RemoteAuthException(
        RemoteAuthFailure.emailAlreadyUsed,
      );

    await expectLater(
      _authUseCase(
        remoteAuthApi: remoteAuthApi,
      ).register(email: 'user@example.com', password: 'password123'),
      throwsA(
        isA<RemoteAuthException>().having(
          (error) => error.failure,
          'failure',
          RemoteAuthFailure.emailAlreadyUsed,
        ),
      ),
    );
  });

  test('AuthUseCases login stores token and account session', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final tokenStore = _FakeAuthTokenStore();
    final accountRepository = _FakeOnlineAccountRepository();
    final remoteAuthApi = _FakeRemoteAuthApi();
    final useCase = _authUseCase(
      tokenStore: tokenStore,
      accountRepository: accountRepository,
      remoteAuthApi: remoteAuthApi,
      clock: clock,
    );

    final session = await useCase.login(
      email: 'user@example.com',
      password: 'password123',
    );

    expect(session.serverUserId, 'server-user-1');
    expect(session.isLoggedIn, isTrue);
    expect(tokenStore.token, 'token-1');
    expect(tokenStore.expiresAt, DateTime.utc(2026, 8, 16, 12));
    expect(accountRepository.session?.lastAuthenticatedAt, clock.now());
  });

  test('AuthUseCases login propagates invalid credentials', () async {
    final remoteAuthApi = _FakeRemoteAuthApi()
      ..loginException = const RemoteAuthException(
        RemoteAuthFailure.invalidCredentials,
      );

    await expectLater(
      _authUseCase(
        remoteAuthApi: remoteAuthApi,
      ).login(email: 'user@example.com', password: 'wrong-password'),
      throwsA(
        isA<RemoteAuthException>().having(
          (error) => error.failure,
          'failure',
          RemoteAuthFailure.invalidCredentials,
        ),
      ),
    );
  });

  test('AuthUseCases logout clears local token even if remote fails', () async {
    final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
    final tokenStore = _FakeAuthTokenStore()
      ..token = 'token-1'
      ..expiresAt = DateTime.utc(2026, 8, 16, 12);
    final accountRepository = _FakeOnlineAccountRepository()
      ..session = UserAccountSession(
        id: 'session-1',
        serverUserId: 'server-user-1',
        email: 'user@example.com',
        isLoggedIn: true,
        createdAt: DateTime.utc(2026, 7, 17, 11),
        updatedAt: DateTime.utc(2026, 7, 17, 11),
      );
    final remoteAuthApi = _FakeRemoteAuthApi()
      ..logoutException = const RemoteAuthException(RemoteAuthFailure.network);

    await _authUseCase(
      tokenStore: tokenStore,
      accountRepository: accountRepository,
      remoteAuthApi: remoteAuthApi,
      clock: clock,
    ).logout();

    expect(tokenStore.token, isNull);
    expect(accountRepository.session?.isLoggedIn, isFalse);
    expect(remoteAuthApi.logoutCalls, 1);
  });

  test('AuthUseCases currentSession reads local cache only', () async {
    final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
    final accountRepository = _FakeOnlineAccountRepository()
      ..session = UserAccountSession(
        id: 'session-1',
        serverUserId: 'server-user-1',
        email: 'user@example.com',
        isLoggedIn: true,
        createdAt: DateTime.utc(2026, 7, 17, 11),
        updatedAt: DateTime.utc(2026, 7, 17, 11),
      );
    final remoteAuthApi = _FakeRemoteAuthApi();

    final session = await _authUseCase(
      tokenStore: tokenStore,
      accountRepository: accountRepository,
      remoteAuthApi: remoteAuthApi,
    ).currentSession();

    expect(session?.email, 'user@example.com');
    expect(remoteAuthApi.registerCalls, 0);
    expect(remoteAuthApi.loginCalls, 0);
    expect(remoteAuthApi.logoutCalls, 0);
  });

  test('SyncUseCases skips silently without token', () async {
    final remoteSyncApi = _FakeRemoteSyncApi();
    final metadataRepository = _FakeSyncMetadataRepository();
    final localChanges = _FakeLocalSyncChangeRepository();

    final summary = await _syncUseCase(
      tokenStore: _FakeAuthTokenStore(),
      remoteSyncApi: remoteSyncApi,
      metadataRepository: metadataRepository,
      localChanges: localChanges,
    ).synchronize(manual: true);

    expect(summary.skipped, isTrue);
    expect(remoteSyncApi.pushCalls, 0);
    expect(remoteSyncApi.pullCalls, 0);
    expect(metadataRepository.saved, isEmpty);
  });

  test('SyncUseCases successful push marks change log rows synced', () async {
    final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
    final localChanges = _FakeLocalSyncChangeRepository()
      ..pendingChanges.add(_pendingExerciseChange());
    final remoteSyncApi = _FakeRemoteSyncApi()
      ..pushResult = RemoteSyncPushResult(
        serverCursor: 'cursor-1',
        results: [
          RemoteSyncPushItemResult(
            resourceType: SyncResourceType.exercise,
            clientId: 'exercise-1',
            serverId: 'server-exercise-1',
            status: RemoteSyncPushStatus.accepted,
            serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
          ),
        ],
      );
    final mappingRepository = _FakeRemoteResourceMappingRepository();

    final summary = await _syncUseCase(
      tokenStore: tokenStore,
      remoteSyncApi: remoteSyncApi,
      mappingRepository: mappingRepository,
      localChanges: localChanges,
    ).synchronize(manual: true);

    expect(summary.pushedChanges, 1);
    expect(localChanges.syncedChangeLogIds, ['change-1']);
    expect(mappingRepository.saved.single.serverId, 'server-exercise-1');
  });

  test('SyncUseCases pull applies a newer remote item', () async {
    final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
    final localChanges = _FakeLocalSyncChangeRepository()
      ..localUpdatedAt = DateTime.utc(2026, 7, 17, 11);
    final remoteSyncApi = _FakeRemoteSyncApi()
      ..pullResult = RemoteSyncPullResult(
        serverCursor: 'cursor-2',
        items: [
          _remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)),
        ],
      );

    final summary = await _syncUseCase(
      tokenStore: tokenStore,
      remoteSyncApi: remoteSyncApi,
      localChanges: localChanges,
    ).synchronize(manual: true);

    expect(summary.pulledChanges, 1);
    expect(localChanges.appliedItems.single.clientId, 'exercise-1');
  });

  test('SyncUseCases pull ignores an older remote item', () async {
    final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
    final localChanges = _FakeLocalSyncChangeRepository()
      ..localUpdatedAt = DateTime.utc(2026, 7, 17, 12);
    final remoteSyncApi = _FakeRemoteSyncApi()
      ..pullResult = RemoteSyncPullResult(
        serverCursor: 'cursor-2',
        items: [
          _remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)),
        ],
      );

    final summary = await _syncUseCase(
      tokenStore: tokenStore,
      remoteSyncApi: remoteSyncApi,
      localChanges: localChanges,
    ).synchronize(manual: true);

    expect(summary.pulledChanges, 0);
    expect(localChanges.appliedItems, isEmpty);
  });

  test('SyncUseCases stores failure status without throwing', () async {
    final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
    final metadataRepository = _FakeSyncMetadataRepository();
    final remoteSyncApi = _FakeRemoteSyncApi()
      ..exception = const RemoteAuthException(RemoteAuthFailure.network);

    final summary = await _syncUseCase(
      tokenStore: tokenStore,
      remoteSyncApi: remoteSyncApi,
      metadataRepository: metadataRepository,
    ).synchronize(manual: true);

    expect(summary.failed, isTrue);
    expect(metadataRepository.metadata.status, OnlineSyncStatus.failure);
    expect(metadataRepository.metadata.lastFailureAt, isNotNull);
  });

  test(
    'ShareUseCases sendShare returns neutral result without token',
    () async {
      final remoteShareApi = _FakeRemoteShareApi();
      final pendingRepository = _FakePendingShareActionRepository();
      final programRepository = _FakeProgramRepository()
        ..programs.add(_shareableProgram());

      final result =
          await _shareUseCase(
            tokenStore: _FakeAuthTokenStore(),
            remoteShareApi: remoteShareApi,
            pendingActionRepository: pendingRepository,
            programRepository: programRepository,
          ).sendShare(
            resourceType: ShareResourceType.program,
            localResourceId: 'program-1',
            recipientEmails: const ['friend@example.com'],
          );

      expect(result.status, ShareSendStatus.notConnected);
      expect(remoteShareApi.sendCalls, 0);
      expect(pendingRepository.actions, isEmpty);
    },
  );

  test('ShareUseCases sendShare queues action when network fails', () async {
    final remoteShareApi = _FakeRemoteShareApi()
      ..exception = const RemoteAuthException(RemoteAuthFailure.network);
    final pendingRepository = _FakePendingShareActionRepository();
    final programRepository = _FakeProgramRepository()
      ..programs.add(_shareableProgram());

    final result =
        await _shareUseCase(
          remoteShareApi: remoteShareApi,
          pendingActionRepository: pendingRepository,
          programRepository: programRepository,
        ).sendShare(
          resourceType: ShareResourceType.program,
          localResourceId: 'program-1',
          recipientEmails: const ['friend@example.com'],
        );

    expect(result.status, ShareSendStatus.queued);
    expect(
      pendingRepository.actions.single.actionType,
      PendingShareActionType.send,
    );
    expect(
      pendingRepository.actions.single.resourceType,
      ShareResourceType.program,
    );
    expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
  });

  test('ShareUseCases acceptShare imports returned resource locally', () async {
    final remoteShareApi = _FakeRemoteShareApi()
      ..acceptResult = _remoteSharedProgramItem();
    final localChanges = _FakeLocalSyncChangeRepository();
    final inboxRepository = _FakeShareInboxRepository()
      ..items.add(_shareInboxItem(status: ShareInboxStatus.pending));

    await _shareUseCase(
      remoteShareApi: remoteShareApi,
      localChanges: localChanges,
      inboxRepository: inboxRepository,
    ).acceptShare('share-1');

    expect(localChanges.appliedItems.single.clientId, 'shared-program-1');
    expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
  });

  test(
    'ShareUseCases acceptShare sans token importe une copie locale indépendante',
    () async {
      final remoteShareApi = _FakeRemoteShareApi();
      final pendingRepository = _FakePendingShareActionRepository();
      final programRepository = _FakeProgramRepository();
      final inboxRepository = _FakeShareInboxRepository()
        ..items.add(
          _shareInboxItem(
            status: ShareInboxStatus.pending,
            payloadJson: jsonEncode(_sharedProgramPayload()),
          ),
        );

      await _shareUseCase(
        tokenStore: _FakeAuthTokenStore(),
        remoteShareApi: remoteShareApi,
        inboxRepository: inboxRepository,
        pendingActionRepository: pendingRepository,
        programRepository: programRepository,
      ).acceptShare('share-1');

      expect(remoteShareApi.acceptCalls, 0);
      expect(
        pendingRepository.actions.single.actionType,
        PendingShareActionType.accept,
      );
      expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
      final imported = programRepository.programs.single;
      expect(imported.metadata.id, isNot('program-from-sender'));
      expect(imported.metadata.originDeviceId, 'device-1');
      expect(imported.name, 'Programme partagé');
      expect(
        imported.exercises.single.metadata.id,
        isNot('exercise-from-sender'),
      );
      expect(imported.exercises.single.sourceExerciseId, isNull);
      expect(
        imported.exercises.single.exerciseStepsSnapshot.single.name,
        'Sprint',
      );
    },
  );

  test('ShareUseCases refreshInbox updates the local cache', () async {
    final remoteShareApi = _FakeRemoteShareApi()
      ..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
    final inboxRepository = _FakeShareInboxRepository();

    final items = await _shareUseCase(
      remoteShareApi: remoteShareApi,
      inboxRepository: inboxRepository,
    ).refreshInbox();

    expect(items.single.shareId, 'share-1');
    expect(inboxRepository.items.single.senderUserId, 'sender-1');
  });

  test('ShareUseCases decline and revoke update local statuses', () async {
    final inboxRepository = _FakeShareInboxRepository()
      ..items.add(_shareInboxItem(status: ShareInboxStatus.pending))
      ..items.add(
        _shareInboxItem(shareId: 'share-2', status: ShareInboxStatus.pending),
      );
    final useCase = _shareUseCase(inboxRepository: inboxRepository);

    await useCase.declineShare('share-1');
    await useCase.revokeShare('share-2');

    expect(
      inboxRepository.items
          .singleWhere((item) => item.shareId == 'share-1')
          .status,
      ShareInboxStatus.declined,
    );
    expect(
      inboxRepository.items
          .singleWhere((item) => item.shareId == 'share-2')
          .status,
      ShareInboxStatus.revoked,
    );
  });

  test('score result enforces manual xor stopwatch values', () {
    expect(
      () => ActiveSetResult(
        metadata: _metadata('result-1'),
        activeWorkoutSessionId: 'session-1',
        programSnapshotId: 'program-1',
        exerciseSnapshotId: 'exercise-1',
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        actualScore: 12,
        actualScoreTimeMs: 12000,
      ),
      throwsA(isA<DomainException>()),
    );
    expect(
      () => ActiveSetResult(
        metadata: _metadata('result-2'),
        activeWorkoutSessionId: 'session-1',
        programSnapshotId: 'program-1',
        exerciseSnapshotId: 'exercise-1',
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        actualScore: 12,
        scoreInputModeSnapshot: ScoreInputMode.stopwatch,
      ),
      throwsA(isA<DomainException>()),
    );
    expect(
      () => ActiveSetResult(
        metadata: _metadata('result-3'),
        activeWorkoutSessionId: 'session-1',
        programSnapshotId: 'program-1',
        exerciseSnapshotId: 'exercise-1',
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        actualScoreTimeMs: 12000,
        scoreInputModeSnapshot: ScoreInputMode.manual,
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test('Tags are normalized and validated on taggable entities', () {
    final exercise = Exercise(
      metadata: _metadata('exercise-tags'),
      name: 'Shoot',
      hasTimeMeasure: false,
      hasRepsMeasure: true,
      hasScoreMeasure: false,
      tags: const ['  Match   Prep ', 'INTENSE'],
    );

    expect(exercise.tags, ['match prep', 'intense']);
    expect(
      () => Program(
        metadata: _metadata('program-tags'),
        name: 'Program',
        defaultRestSeconds: 30,
        tags: const ['match', 'MATCH'],
      ),
      throwsA(isA<DomainException>()),
    );
    expect(
      () => WorkoutTemplate(
        metadata: _metadata('template-tags'),
        name: 'Template',
        tags: List.generate(9, (index) => 'tag$index'),
      ),
      throwsA(isA<DomainException>()),
    );
    expect(
      () => Exercise(
        metadata: _metadata('exercise-long-tag'),
        name: 'Shoot',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        tags: const ['tag beaucoup trop long pour le mvp'],
      ),
      throwsA(isA<DomainException>()),
    );
  });

  test('Tag helpers filter by all selected tags and sort suggestions', () {
    final programs = [
      Program(
        metadata: _metadata('program-1'),
        name: 'A',
        defaultRestSeconds: 30,
        tags: const ['match', 'intense'],
      ),
      Program(
        metadata: _metadata('program-2'),
        name: 'B',
        defaultRestSeconds: 30,
        tags: const ['match', 'extérieur'],
      ),
      Program(
        metadata: _metadata('program-3'),
        name: 'C',
        defaultRestSeconds: 30,
        tags: const ['intense'],
      ),
    ];

    final filtered = filterByRequiredTags(programs, {
      ' MATCH ',
      'intense',
    }, (program) => program.tags);
    final suggestions = tagSuggestionsFor(programs, (program) => program.tags);

    expect(filtered.map((program) => program.metadata.id), ['program-1']);
    expect(suggestions.map((usage) => '${usage.tag}:${usage.count}'), [
      'intense:2',
      'match:2',
      'extérieur:1',
    ]);
  });

  test('Local backup codec writes v1 envelope and preview counts', () {
    final codec = LocalBackupCodec();
    final snapshot = _backupSnapshot(
      exportedAt: DateTime.utc(2026, 7, 22, 12),
      exercises: [
        _backupResource(
          id: 'exercise-1',
          updatedAt: DateTime.utc(2026, 7, 22, 10),
          payload: {
            'id': 'exercise-1',
            'metadata': _backupMetadata(
              'exercise-1',
              DateTime.utc(2026, 7, 22, 10),
            ),
            'name': 'Shoot',
          },
        ),
      ],
      mediaFiles: const [
        EmbeddedBackupMediaFile(
          mediaAssetId: 'media-1',
          role: 'original',
          fileName: 'media-1.png',
          mimeType: 'image/png',
          sizeBytes: 4,
          sha256:
              '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
          base64: 'dGVzdA==',
        ),
      ],
    );

    final decodedJson = jsonDecode(utf8.decode(codec.encode(snapshot))) as Map;
    final preview = codec.preview(codec.encode(snapshot));

    expect(decodedJson['kind'], 'gametime.localBackup');
    expect(decodedJson['formatVersion'], 1);
    expect(preview.counts.exercises, 1);
    expect(preview.counts.embeddedMediaFiles, 1);
    expect(preview.hasEmbeddedMedia, isTrue);
    expect(preview.missingMediaCount, 0);
  });

  test('Local backup codec rejects invalid and future files', () {
    final codec = LocalBackupCodec();

    expect(
      () => codec.decode(Uint8List.fromList(utf8.encode('not json'))),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.corrupted,
        ),
      ),
    );
    final future = utf8.encode(
      jsonEncode({
        'kind': 'gametime.localBackup',
        'formatVersion': 2,
        'minSupportedFormatVersion': 2,
        'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
        'data': {
          'mediaAssets': const [],
          'exercises': const [],
          'programs': const [],
          'workoutTemplates': const [],
          'workoutHistories': const [],
        },
        'mediaFiles': const [],
      }),
    );
    expect(
      () => codec.decode(Uint8List.fromList(future)),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.newerVersion,
        ),
      ),
    );
  });

  test('Data export use case embeds media and names gametime file', () async {
    final repository = _FakeLocalDataBackupRepository(
      snapshot: _backupSnapshot(
        exportedAt: DateTime.utc(2026, 7, 22),
        mediaAssets: [
          _backupResource(
            id: 'media-1',
            updatedAt: DateTime.utc(2026, 7, 22, 10),
            payload: {
              'id': 'media-1',
              'metadata': _backupMetadata(
                'media-1',
                DateTime.utc(2026, 7, 22, 10),
              ),
              'localUri': 'file:///tmp/media-1.png',
            },
          ),
        ],
      ),
    );
    final mediaStore = _FakeLocalBackupMediaStore(
      files: const [
        EmbeddedBackupMediaFile(
          mediaAssetId: 'media-1',
          role: 'original',
          fileName: 'media-1.png',
          mimeType: 'image/png',
          sizeBytes: 4,
          sha256:
              '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
          base64: 'dGVzdA==',
        ),
      ],
    );
    final useCase = DataExportUseCase(
      repository: repository,
      mediaStore: mediaStore,
      clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
    );

    final document = await useCase.exportAll();
    final preview = const LocalBackupCodec().preview(document.bytes);

    expect(document.fileName, 'gametime-sauvegarde-2026-07-22.gametime');
    expect(mediaStore.requestedMediaIds, ['media-1']);
    expect(preview.counts.mediaAssets, 1);
    expect(preview.counts.embeddedMediaFiles, 1);
  });

  test('Data import use case blocks when active workout is open', () async {
    final repository = _FakeLocalDataBackupRepository(hasOpenSession: true);
    final useCase = DataImportUseCase(
      repository: repository,
      mediaStore: _FakeLocalBackupMediaStore(),
      clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
    );
    final bytes = const LocalBackupCodec().encode(_backupSnapshot());

    await expectLater(
      useCase.importFrom(bytes, mode: LocalBackupImportMode.merge),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.activeWorkoutInProgress,
        ),
      ),
    );
    expect(repository.appliedModes, isEmpty);
  });

  test('Data import use case restores media and applies selected mode', () async {
    final repository = _FakeLocalDataBackupRepository();
    final mediaStore = _FakeLocalBackupMediaStore(
      restored: const {
        'media-1': RestoredMediaFile(
          mediaAssetId: 'media-1',
          localUri: 'file:///restored/media-1.png',
          sizeBytes: 4,
        ),
      },
    );
    final useCase = DataImportUseCase(
      repository: repository,
      mediaStore: mediaStore,
      clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
    );
    final bytes = const LocalBackupCodec().encode(
      _backupSnapshot(
        mediaAssets: [
          _backupResource(
            id: 'media-1',
            updatedAt: DateTime.utc(2026, 7, 22, 10),
            payload: {
              'id': 'media-1',
              'metadata': _backupMetadata(
                'media-1',
                DateTime.utc(2026, 7, 22, 10),
              ),
              'localUri': 'file:///old/media-1.png',
            },
          ),
        ],
        mediaFiles: const [
          EmbeddedBackupMediaFile(
            mediaAssetId: 'media-1',
            role: 'original',
            fileName: 'media-1.png',
            mimeType: 'image/png',
            sizeBytes: 4,
            sha256:
                '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
            base64: 'dGVzdA==',
          ),
        ],
      ),
    );

    final result = await useCase.importFrom(
      bytes,
      mode: LocalBackupImportMode.replaceAll,
    );

    expect(result.insertedCount, 0);
    expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]);
    expect(
      repository.appliedSnapshots.single.mediaAssets.single.payload['localUri'],
      'file:///restored/media-1.png',
    );
  });

  test('Local backup codec rejects wrong kind and incompatible format', () {
    final codec = LocalBackupCodec();

    final wrongKind = utf8.encode(
      jsonEncode({
        'kind': 'other.app.backup',
        'formatVersion': 1,
        'minSupportedFormatVersion': 1,
        'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
        'data': {
          'mediaAssets': const [],
          'exercises': const [],
          'programs': const [],
          'workoutTemplates': const [],
          'workoutHistories': const [],
        },
        'mediaFiles': const [],
      }),
    );
    expect(
      () => codec.decode(Uint8List.fromList(wrongKind)),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.invalidFile,
        ),
      ),
    );

    final missingVersion = utf8.encode(
      jsonEncode({
        'kind': 'gametime.localBackup',
        'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
        'data': {
          'mediaAssets': const [],
          'exercises': const [],
          'programs': const [],
          'workoutTemplates': const [],
          'workoutHistories': const [],
        },
        'mediaFiles': const [],
      }),
    );
    expect(
      () => codec.decode(Uint8List.fromList(missingVersion)),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.incompatibleFormat,
        ),
      ),
    );
  });

  test('Local backup codec rejects malformed data collections', () {
    final codec = LocalBackupCodec();

    final malformedData = utf8.encode(
      jsonEncode({
        'kind': 'gametime.localBackup',
        'formatVersion': 1,
        'minSupportedFormatVersion': 1,
        'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
        'data': 'not-a-map',
        'mediaFiles': const [],
      }),
    );
    expect(
      () => codec.decode(Uint8List.fromList(malformedData)),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.corrupted,
        ),
      ),
    );
  });

  test(
    'Data import use case wraps unexpected repository failure as importFailedNoMutation',
    () async {
      final repository = _ThrowingLocalDataBackupRepository();
      final useCase = DataImportUseCase(
        repository: repository,
        mediaStore: _FakeLocalBackupMediaStore(),
        clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
      );
      final bytes = const LocalBackupCodec().encode(_backupSnapshot());

      await expectLater(
        useCase.importFrom(bytes, mode: LocalBackupImportMode.merge),
        throwsA(
          isA<LocalBackupException>().having(
            (error) => error.error,
            'error',
            LocalBackupValidationError.importFailedNoMutation,
          ),
        ),
      );
    },
  );

  test(
    'Data import use case counts media assets not successfully restored',
    () async {
      final repository = _FakeLocalDataBackupRepository();
      final mediaStore = _FakeLocalBackupMediaStore(
        restored: const {
          'media-restored': RestoredMediaFile(
            mediaAssetId: 'media-restored',
            localUri: 'file:///restored/media-restored.png',
            sizeBytes: 4,
          ),
        },
      );
      final useCase = DataImportUseCase(
        repository: repository,
        mediaStore: mediaStore,
        clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
      );
      final bytes = const LocalBackupCodec().encode(
        _backupSnapshot(
          mediaAssets: [
            _backupResource(
              id: 'media-restored',
              updatedAt: DateTime.utc(2026, 7, 22, 10),
              payload: {
                'id': 'media-restored',
                'metadata': _backupMetadata(
                  'media-restored',
                  DateTime.utc(2026, 7, 22, 10),
                ),
                'localUri': 'file:///old/media-restored.png',
              },
            ),
            _backupResource(
              id: 'media-corrupted',
              updatedAt: DateTime.utc(2026, 7, 22, 10),
              payload: {
                'id': 'media-corrupted',
                'metadata': _backupMetadata(
                  'media-corrupted',
                  DateTime.utc(2026, 7, 22, 10),
                ),
                'localUri': 'file:///old/media-corrupted.png',
              },
            ),
          ],
          mediaFiles: const [
            EmbeddedBackupMediaFile(
              mediaAssetId: 'media-restored',
              role: 'original',
              fileName: 'media-restored.png',
              mimeType: 'image/png',
              sizeBytes: 4,
              sha256:
                  '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
              base64: 'dGVzdA==',
            ),
            EmbeddedBackupMediaFile(
              mediaAssetId: 'media-corrupted',
              role: 'original',
              fileName: 'media-corrupted.png',
              mimeType: 'image/png',
              sizeBytes: 4,
              sha256: 'checksum-mismatch',
              base64: 'dGVzdA==',
            ),
          ],
        ),
      );

      final result = await useCase.importFrom(
        bytes,
        mode: LocalBackupImportMode.merge,
      );

      expect(result.missingMediaCount, 1);
    },
  );

  test('Program use case duplicates deeply with copy name conflicts', () async {
    final programRepository = _FakeProgramRepository();
    final templateRepository = _FakeWorkoutTemplateRepository();
    final source = Program(
      metadata: _metadata('program-source'),
      name: 'Programme tirs',
      defaultRestSeconds: 45,
      isExample: true,
      tags: const ['match', 'extérieur'],
      exercises: [
        _programExercise(
          id: 'program-exercise-source',
          programId: 'program-source',
          sourceExerciseId: 'exercise-source',
          position: 0,
        ),
      ],
    );
    programRepository.programs.addAll([
      source,
      Program(
        metadata: _metadata('program-copy-1'),
        name: 'Copie de Programme tirs',
        defaultRestSeconds: 45,
      ),
    ]);
    final useCase = ProgramUseCases(
      programRepository: programRepository,
      exerciseRepository: _FakeExerciseRepository(),
      templateRepository: templateRepository,
      clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    final copy = await useCase.duplicate('program-source');

    expect(copy.metadata.id, isNot('program-source'));
    expect(copy.name, 'Copie 2 de Programme tirs');
    expect(copy.isExample, isFalse);
    expect(copy.tags, source.tags);
    expect(copy.exercises.single.metadata.id, isNot('program-exercise-source'));
    expect(copy.exercises.single.programId, copy.metadata.id);
    expect(copy.exercises.single.sourceExerciseId, 'exercise-source');
    expect(copy.exercises.single.targetReps, 10);
    expect(
      programRepository.programs
          .singleWhere((program) => program.metadata.id == 'program-source')
          .exercises
          .single
          .metadata
          .id,
      'program-exercise-source',
    );
  });

  test(
    'Workout template use case duplicates deeply and remaps overrides',
    () async {
      final templateRepository = _FakeWorkoutTemplateRepository();
      final source = WorkoutTemplate(
        metadata: _metadata('template-source'),
        name: 'Prépa match',
        lastStartedAt: DateTime.utc(2026, 7, 20, 12),
        isExample: true,
        tags: const ['match', 'intense'],
        programs: [
          _templateProgram(
            id: 'template-program-source',
            workoutTemplateId: 'template-source',
            sourceProgramId: 'program-source',
            position: 0,
          ),
        ],
        overrides: [
          WorkoutTemplateExerciseOverride(
            metadata: _metadata('override-source'),
            workoutTemplateProgramId: 'template-program-source',
            snapshotProgramExerciseId: 'program-exercise-source',
            setsCountOverride: 4,
            targetRepsOverride: 12,
            autoStartNextTimedStepOverride: false,
          ),
        ],
      );
      templateRepository.templates.addAll([
        source,
        WorkoutTemplate(
          metadata: _metadata('template-copy-1'),
          name: 'Copie de Prépa match',
        ),
      ]);
      final useCase = WorkoutTemplateUseCases(
        templateRepository: templateRepository,
        programRepository: _FakeProgramRepository(),
        clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final copy = await useCase.duplicate('template-source');

      expect(copy.metadata.id, isNot('template-source'));
      expect(copy.name, 'Copie 2 de Prépa match');
      expect(copy.lastStartedAt, isNull);
      expect(copy.isExample, isFalse);
      expect(copy.tags, source.tags);
      expect(
        copy.programs.single.metadata.id,
        isNot('template-program-source'),
      );
      expect(copy.programs.single.workoutTemplateId, copy.metadata.id);
      expect(copy.overrides.single.metadata.id, isNot('override-source'));
      expect(
        copy.overrides.single.workoutTemplateProgramId,
        copy.programs.single.metadata.id,
      );
      expect(
        templateRepository.templates
            .singleWhere(
              (template) => template.metadata.id == 'template-source',
            )
            .programs
            .single
            .metadata
            .id,
        'template-program-source',
      );
      expect(
        copy.overrides.single.snapshotProgramExerciseId,
        'program-exercise-source',
      );
      expect(copy.overrides.single.setsCountOverride, 4);
      expect(copy.overrides.single.targetRepsOverride, 12);
      expect(copy.overrides.single.autoStartNextTimedStepOverride, isFalse);
    },
  );

  test('Progression stats use case resolves four week overview', () async {
    final repository = _FakeProgressionStatsRepository();
    final now = DateTime.utc(2026, 7, 22, 12);
    repository.globalStats = ProgressionGlobalStatsData(
      completedSessionCount: 2,
      totalActiveMs: 180000,
      activeWeekStarts: [DateTime(2026, 7, 13), DateTime(2026, 7, 20)],
      hasAnyCompletedHistory: true,
    );
    repository.exerciseOptions = [
      ProgressionExerciseOptionData(
        exerciseKey: 'exercise-1',
        nameSnapshot: 'Shoot',
        isArchived: false,
        lastPerformedAt: now,
      ),
    ];
    repository.measureOptions['exercise-1'] = const [
      ProgressionMeasureOptionData(
        measure: ProgressionMeasure.manualScore,
        label: 'Score',
        scoreLabel: 'Réussites',
        scoreUnit: 'paniers',
        lowerIsBetter: false,
      ),
    ];

    final overview = await ProgressionStatsUseCase(
      repository: repository,
      clock: _FakeClock(now),
    ).getOverview(ProgressionPeriod.fourWeeks);

    expect(repository.lastGlobalRange!.startedAt, DateTime(2026, 6, 29));
    expect(repository.lastGlobalRange!.endedAt, now);
    expect(overview.totalWeekCount, 4);
    expect(overview.activeWeekCount, 2);
    expect(overview.rangeLabelKind, ProgressionRangeLabelKind.sinceDate);
    expect(
      overview.exerciseOptions.single.measures.single.scoreUnit,
      'paniers',
    );
  });

  test('Progression stats use case resolves all-time empty overview', () async {
    final repository = _FakeProgressionStatsRepository();
    repository.globalStats = const ProgressionGlobalStatsData(
      completedSessionCount: 0,
      totalActiveMs: 0,
      activeWeekStarts: [],
      hasAnyCompletedHistory: false,
    );

    final overview = await ProgressionStatsUseCase(
      repository: repository,
      clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
    ).getOverview(ProgressionPeriod.all);

    expect(repository.lastGlobalRange!.startedAt, isNull);
    expect(overview.totalWeekCount, isNull);
    expect(overview.rangeLabelKind, ProgressionRangeLabelKind.none);
    expect(overview.hasAnyCompletedHistory, isFalse);
  });

  test(
    'Progression stats use case maps series summaries and empty period',
    () async {
      final repository = _FakeProgressionStatsRepository();
      final now = DateTime.utc(2026, 7, 22, 12);
      repository.series = ProgressionExerciseSeriesData(
        exerciseKey: 'exercise-1',
        exerciseNameSnapshot: 'Shoot',
        measure: const ProgressionMeasureOptionData(
          measure: ProgressionMeasure.reps,
          label: 'Répétitions',
          lowerIsBetter: false,
        ),
        points: [
          ProgressionPointData(
            workoutHistoryId: 'history-1',
            workoutNameSnapshot: 'Séance 1',
            startedAt: now.subtract(const Duration(days: 1)),
            value: 12,
            rawValue: 12,
          ),
          ProgressionPointData(
            workoutHistoryId: 'history-2',
            workoutNameSnapshot: 'Séance 2',
            startedAt: now,
            value: 15,
            rawValue: 15,
          ),
        ],
        hasAnyAllTimeData: true,
        hasAnyCompletedExerciseResult: true,
      );

      final series =
          await ProgressionStatsUseCase(
            repository: repository,
            clock: _FakeClock(now),
          ).getExerciseSeries(
            period: ProgressionPeriod.fourWeeks,
            exerciseKey: 'exercise-1',
            measure: ProgressionMeasure.reps,
          );

      expect(series.state, ProgressionSeriesState.ready);
      expect(series.summary.kind, ProgressionSeriesSummaryKind.totals);
      expect(series.summary.recentTotal, 27);
      expect(series.summary.lastSessionTotal, 15);

      repository.series = const ProgressionExerciseSeriesData(
        exerciseKey: 'exercise-1',
        exerciseNameSnapshot: 'Shoot',
        measure: ProgressionMeasureOptionData(
          measure: ProgressionMeasure.manualScore,
          label: 'Score',
          lowerIsBetter: false,
        ),
        points: [],
        hasAnyAllTimeData: true,
        hasAnyCompletedExerciseResult: true,
      );
      final empty =
          await ProgressionStatsUseCase(
            repository: repository,
            clock: _FakeClock(now),
          ).getExerciseSeries(
            period: ProgressionPeriod.fourWeeks,
            exerciseKey: 'exercise-1',
            measure: ProgressionMeasure.manualScore,
          );

      expect(empty.state, ProgressionSeriesState.emptyButHasAllTimeData);
      expect(empty.summary.kind, ProgressionSeriesSummaryKind.none);

      repository.series = const ProgressionExerciseSeriesData(
        exerciseKey: 'exercise-1',
        exerciseNameSnapshot: 'Shoot',
        measure: ProgressionMeasureOptionData(
          measure: ProgressionMeasure.manualScore,
          label: 'Score',
          lowerIsBetter: false,
        ),
        points: [],
        hasAnyAllTimeData: false,
        hasAnyCompletedExerciseResult: true,
      );
      final noGraphable =
          await ProgressionStatsUseCase(
            repository: repository,
            clock: _FakeClock(now),
          ).getExerciseSeries(
            period: ProgressionPeriod.all,
            exerciseKey: 'exercise-1',
            measure: ProgressionMeasure.manualScore,
          );

      expect(noGraphable.state, ProgressionSeriesState.noGraphableMeasure);
    },
  );
}

ExerciseStep _step({
  String id = 'step-1',
  int position = 0,
  String name = 'Phase',
  ExerciseStepType type = ExerciseStepType.time,
  int defaultTargetValue = 30,
  bool hasScore = false,
  ScoreInputMode scoreInputMode = ScoreInputMode.manual,
  String? scoreLabel,
  String? scoreUnit,
  double? defaultTargetScore,
  int? defaultTargetScoreTimeMs,
}) {
  return ExerciseStep(
    id: id,
    position: position,
    name: name,
    type: type,
    defaultTargetValue: defaultTargetValue,
    hasScore: hasScore,
    scoreInputMode: scoreInputMode,
    scoreLabel: scoreLabel,
    scoreUnit: scoreUnit,
    defaultTargetScore: defaultTargetScore,
    defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
  );
}

EntityMetadata _metadata(String id) {
  return EntityMetadata(
    id: id,
    createdAt: DateTime.utc(2026, 7, 17),
    updatedAt: DateTime.utc(2026, 7, 17),
    originDeviceId: 'device-1',
  );
}

final class _FakeClock implements Clock {
  _FakeClock(this.value);

  DateTime value;

  @override
  DateTime now() => value;
}

final class _FakeIds implements IdGenerator {
  var _next = 0;

  @override
  String newId() {
    _next += 1;
    return 'id-$_next';
  }
}

final class _FakeProgressionStatsRepository
    implements ProgressionStatsRepository {
  ProgressionDateRange? lastGlobalRange;
  ProgressionDateRange? lastSeriesRange;
  ProgressionGlobalStatsData globalStats = const ProgressionGlobalStatsData(
    completedSessionCount: 0,
    totalActiveMs: 0,
    activeWeekStarts: [],
    hasAnyCompletedHistory: false,
  );
  List<ProgressionExerciseOptionData> exerciseOptions = const [];
  final measureOptions = <String, List<ProgressionMeasureOptionData>>{};
  ProgressionExerciseSeriesData series = const ProgressionExerciseSeriesData(
    exerciseKey: 'exercise-1',
    exerciseNameSnapshot: 'Exercise',
    measure: ProgressionMeasureOptionData(
      measure: ProgressionMeasure.manualScore,
      label: 'Score',
      lowerIsBetter: false,
    ),
    points: [],
    hasAnyAllTimeData: false,
    hasAnyCompletedExerciseResult: false,
  );

  @override
  Future<ProgressionGlobalStatsData> readGlobalStats(
    ProgressionDateRange range,
  ) async {
    lastGlobalRange = range;
    return globalStats;
  }

  @override
  Future<List<ProgressionExerciseOptionData>> listExerciseOptions(
    ProgressionDateRange range,
  ) async {
    return exerciseOptions;
  }

  @override
  Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
    required ProgressionDateRange range,
    required String exerciseKey,
  }) async {
    return measureOptions[exerciseKey] ?? const [];
  }

  @override
  Future<ProgressionExerciseSeriesData> readExerciseSeries({
    required ProgressionDateRange range,
    required String exerciseKey,
    required ProgressionMeasure measure,
  }) async {
    lastSeriesRange = range;
    return series;
  }
}

AuthUseCases _authUseCase({
  _FakeAuthTokenStore? tokenStore,
  _FakeOnlineAccountRepository? accountRepository,
  _FakeRemoteAuthApi? remoteAuthApi,
  _FakeClock? clock,
}) {
  return AuthUseCases(
    tokenStore: tokenStore ?? _FakeAuthTokenStore(),
    accountRepository: accountRepository ?? _FakeOnlineAccountRepository(),
    remoteAuthApi: remoteAuthApi ?? _FakeRemoteAuthApi(),
    clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
    ids: _FakeIds(),
  );
}

final class _FakeAuthTokenStore implements AuthTokenStore {
  String? token;
  DateTime? expiresAt;

  @override
  Future<void> clearToken() async {
    token = null;
    expiresAt = null;
  }

  @override
  Future<String?> readToken() async => token;

  @override
  Future<void> saveToken(String token, DateTime expiresAt) async {
    this.token = token;
    this.expiresAt = expiresAt;
  }
}

final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
  UserAccountSession? session;
  var clearCalls = 0;

  @override
  Future<void> clearSession() async {
    clearCalls += 1;
    session = null;
  }

  @override
  Future<UserAccountSession?> currentSession() async => session;

  @override
  Future<void> saveSession(UserAccountSession session) async {
    this.session = session;
  }
}

final class _FakeRemoteAuthApi implements RemoteAuthApi {
  RemoteAuthException? registerException;
  RemoteAuthException? loginException;
  RemoteAuthException? logoutException;
  var registerCalls = 0;
  var loginCalls = 0;
  var logoutCalls = 0;

  @override
  Future<RemoteAuthResult> register({
    required String email,
    required String password,
    String? displayName,
  }) async {
    registerCalls += 1;
    final exception = registerException;
    if (exception != null) {
      throw exception;
    }
    return _authResult(email);
  }

  @override
  Future<RemoteAuthResult> login({
    required String email,
    required String password,
  }) async {
    loginCalls += 1;
    final exception = loginException;
    if (exception != null) {
      throw exception;
    }
    return _authResult(email);
  }

  @override
  Future<void> logout(String token) async {
    logoutCalls += 1;
    final exception = logoutException;
    if (exception != null) {
      throw exception;
    }
  }

  RemoteAuthResult _authResult(String email) {
    return RemoteAuthResult(
      userId: 'server-user-1',
      email: email.trim().toLowerCase(),
      token: 'token-1',
      expiresAt: DateTime.utc(2026, 8, 16, 12),
    );
  }
}

SyncUseCases _syncUseCase({
  _FakeAuthTokenStore? tokenStore,
  _FakeRemoteSyncApi? remoteSyncApi,
  _FakeSyncMetadataRepository? metadataRepository,
  _FakeRemoteResourceMappingRepository? mappingRepository,
  _FakeLocalSyncChangeRepository? localChanges,
  _FakeClock? clock,
}) {
  return SyncUseCases(
    tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
    remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(),
    metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(),
    mappingRepository:
        mappingRepository ?? _FakeRemoteResourceMappingRepository(),
    localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
    clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
    deviceId: 'device-1',
  );
}

final class _FakeRemoteSyncApi implements RemoteSyncApi {
  RemoteSyncPushResult pushResult = const RemoteSyncPushResult(
    serverCursor: null,
    results: [],
  );
  RemoteSyncPullResult pullResult = const RemoteSyncPullResult(
    serverCursor: null,
    items: [],
  );
  Exception? exception;
  var pushCalls = 0;
  var pullCalls = 0;

  @override
  Future<RemoteSyncPushResult> push({
    required String deviceId,
    required List<RemoteSyncPushItem> items,
    required String token,
  }) async {
    pushCalls += 1;
    final error = exception;
    if (error != null) {
      throw error;
    }
    return pushResult;
  }

  @override
  Future<RemoteSyncPullResult> pull({
    required String? since,
    required String token,
  }) async {
    pullCalls += 1;
    final error = exception;
    if (error != null) {
      throw error;
    }
    return pullResult;
  }
}

final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
  SyncMetadataSnapshot metadata = const SyncMetadataSnapshot();
  final saved = <SyncMetadataSnapshot>[];

  @override
  Future<SyncMetadataSnapshot> read() async => metadata;

  @override
  Future<void> save(SyncMetadataSnapshot metadata) async {
    this.metadata = metadata;
    saved.add(metadata);
  }
}

final class _FakeRemoteResourceMappingRepository
    implements RemoteResourceMappingRepository {
  final saved = <RemoteResourceMapping>[];

  @override
  Future<RemoteResourceMapping?> find({
    required SyncResourceType resourceType,
    required String clientId,
  }) async {
    return saved
        .where(
          (mapping) =>
              mapping.resourceType == resourceType &&
              mapping.clientId == clientId,
        )
        .firstOrNull;
  }

  @override
  Future<void> save(RemoteResourceMapping mapping) async {
    saved.removeWhere(
      (existing) =>
          existing.resourceType == mapping.resourceType &&
          existing.clientId == mapping.clientId,
    );
    saved.add(mapping);
  }
}

final class _FakeLocalSyncChangeRepository
    implements LocalSyncChangeRepository {
  final pendingChanges = <PendingSyncChange>[];
  final syncedChangeLogIds = <String>[];
  final appliedItems = <RemoteSyncedItem>[];
  DateTime? localUpdatedAt;

  @override
  Future<List<PendingSyncChange>> listPendingChanges() async {
    return pendingChanges;
  }

  @override
  Future<void> markChangesSynced(
    List<String> changeLogIds,
    DateTime syncedAt,
  ) async {
    syncedChangeLogIds.addAll(changeLogIds);
  }

  @override
  Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
    final updatedAt = localUpdatedAt;
    if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) {
      return false;
    }
    appliedItems.add(item);
    localUpdatedAt = item.clientUpdatedAt;
    return true;
  }
}

PendingSyncChange _pendingExerciseChange() {
  return PendingSyncChange(
    changeLogIds: const ['change-1'],
    item: RemoteSyncPushItem(
      resourceType: SyncResourceType.exercise,
      clientId: 'exercise-1',
      schemaVersion: 1,
      clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
      deletedAt: null,
      payload: const {'id': 'exercise-1', 'name': 'Squat'},
    ),
  );
}

RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
  return RemoteSyncedItem(
    resourceType: SyncResourceType.exercise,
    clientId: 'exercise-1',
    serverId: 'server-exercise-1',
    schemaVersion: 1,
    clientUpdatedAt: clientUpdatedAt,
    serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
    deletedAt: null,
    payload: const {'id': 'exercise-1', 'name': 'Squat'},
  );
}

ShareUseCases _shareUseCase({
  _FakeAuthTokenStore? tokenStore,
  _FakeRemoteShareApi? remoteShareApi,
  _FakeShareInboxRepository? inboxRepository,
  _FakePendingShareActionRepository? pendingActionRepository,
  _FakeLocalSyncChangeRepository? localChanges,
  _FakeProgramRepository? programRepository,
  _FakeWorkoutTemplateRepository? templateRepository,
}) {
  return ShareUseCases(
    tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
    remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
    inboxRepository: inboxRepository ?? _FakeShareInboxRepository(),
    pendingActionRepository:
        pendingActionRepository ?? _FakePendingShareActionRepository(),
    localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
    programRepository: programRepository ?? _FakeProgramRepository(),
    templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
    clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
    ids: _FakeIds(),
    originDeviceId: 'device-1',
  );
}

final class _FakeRemoteShareApi implements RemoteShareApi {
  Exception? exception;
  RemoteSyncedItem? acceptResult;
  List<ShareInboxItem> inboxItems = const [];
  var sendCalls = 0;
  var acceptCalls = 0;
  var declineCalls = 0;
  var revokeCalls = 0;

  @override
  Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
    acceptCalls += 1;
    final error = exception;
    if (error != null) {
      throw error;
    }
    return acceptResult ?? _remoteSharedProgramItem();
  }

  @override
  Future<void> declineShare(String shareId, String token) async {
    declineCalls += 1;
    final error = exception;
    if (error != null) {
      throw error;
    }
  }

  @override
  Future<List<ShareInboxItem>> fetchInbox(String token) async {
    final error = exception;
    if (error != null) {
      throw error;
    }
    return inboxItems;
  }

  @override
  Future<void> revokeShare(String shareId, String token) async {
    revokeCalls += 1;
    final error = exception;
    if (error != null) {
      throw error;
    }
  }

  @override
  Future<RemoteShareSendResult> sendShare({
    required ShareResourceType resourceType,
    required Map<String, Object?> payload,
    required List<String> recipientEmails,
    required String token,
  }) async {
    sendCalls += 1;
    final error = exception;
    if (error != null) {
      throw error;
    }
    return const RemoteShareSendResult(
      shareId: 'share-1',
      recipientUserIds: ['server-user-2'],
      unresolvedEmails: [],
    );
  }
}

final class _FakeShareInboxRepository implements ShareInboxRepository {
  final items = <ShareInboxItem>[];

  @override
  Future<ShareInboxItem?> findByShareId(String shareId) async {
    return items.where((item) => item.shareId == shareId).firstOrNull;
  }

  @override
  Future<List<ShareInboxItem>> listAll() async => List.of(items);

  @override
  Future<void> markStatus(
    String shareId,
    ShareInboxStatus status,
    DateTime respondedAt,
  ) async {
    final index = items.indexWhere((item) => item.shareId == shareId);
    if (index == -1) {
      return;
    }
    items[index] = items[index].copyWith(
      status: status,
      respondedAt: respondedAt,
    );
  }

  @override
  Future<void> upsert(ShareInboxItem item) async {
    items.removeWhere((existing) => existing.shareId == item.shareId);
    items.add(item);
  }

  @override
  Future<void> upsertAll(List<ShareInboxItem> items) async {
    for (final item in items) {
      await upsert(item);
    }
  }
}

final class _FakePendingShareActionRepository
    implements PendingShareActionRepository {
  final actions = <PendingShareAction>[];

  @override
  Future<void> add(PendingShareAction action) async {
    actions.add(action);
  }

  @override
  Future<List<PendingShareAction>> listPending() async {
    return actions
        .where((action) => action.status != PendingShareActionStatus.succeeded)
        .toList();
  }

  @override
  Future<void> markFailed(String id, DateTime attemptedAt) async {
    _mark(id, attemptedAt, PendingShareActionStatus.failed);
  }

  @override
  Future<void> markSucceeded(String id, DateTime attemptedAt) async {
    _mark(id, attemptedAt, PendingShareActionStatus.succeeded);
  }

  void _mark(String id, DateTime attemptedAt, PendingShareActionStatus status) {
    final index = actions.indexWhere((action) => action.id == id);
    if (index == -1) {
      return;
    }
    final action = actions[index];
    actions[index] = action.copyWith(
      lastAttemptAt: attemptedAt,
      attemptCount: action.attemptCount + 1,
      status: status,
    );
  }
}

Program _shareableProgram() {
  return Program(
    metadata: _metadata('program-1'),
    name: 'Programme',
    defaultRestSeconds: 60,
  );
}

ShareInboxItem _shareInboxItem({
  String shareId = 'share-1',
  required ShareInboxStatus status,
  String payloadJson = '{"name":"Programme partagé"}',
}) {
  return ShareInboxItem(
    shareId: shareId,
    senderUserId: 'sender-1',
    resourceType: ShareResourceType.program,
    payloadJson: payloadJson,
    status: status,
    createdAt: DateTime.utc(2026, 7, 17, 12),
  );
}

Map<String, Object?> _sharedProgramPayload() {
  return {
    'id': 'program-from-sender',
    'name': 'Programme partagé',
    'defaultRestSeconds': 45,
    'exercises': [
      {
        'id': 'exercise-from-sender',
        'sourceExerciseId': 'source-exercise-from-sender',
        'position': 0,
        'exerciseNameSnapshot': 'Sprint',
        'exerciseStepsSnapshot': [
          {
            'id': 'step-from-sender',
            'position': 0,
            'name': 'Sprint',
            'type': 'time',
            'defaultTargetValue': 20,
            'hasScore': false,
            'scoreInputMode': 'manual',
            'scoreLabel': null,
            'scoreUnit': null,
            'defaultTargetScore': null,
            'defaultTargetScoreTimeMs': null,
          },
        ],
        'availableTimeSnapshot': true,
        'availableRepsSnapshot': false,
        'availableScoreSnapshot': false,
        'scoreInputModeSnapshot': 'manual',
        'setsCount': 2,
        'timeEnabled': true,
        'repsEnabled': false,
        'scoreEnabled': false,
        'targetTimeSeconds': 20,
      },
    ],
  };
}

RemoteSyncedItem _remoteSharedProgramItem() {
  return RemoteSyncedItem(
    resourceType: SyncResourceType.program,
    clientId: 'shared-program-1',
    serverId: 'server-program-1',
    schemaVersion: 1,
    clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
    serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
    deletedAt: null,
    payload: const {'id': 'shared-program-1', 'name': 'Programme partagé'},
  );
}

ExerciseUseCases _exerciseUseCase(
  _FakeExerciseRepository repository, {
  _FakeProgramRepository? programRepository,
}) {
  return ExerciseUseCases(
    repository: repository,
    programRepository: programRepository ?? _FakeProgramRepository(),
    clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
    ids: _FakeIds(),
    originDeviceId: 'device-1',
  );
}

ProgramExercise _programExercise({
  required String id,
  required String programId,
  required String sourceExerciseId,
  required int position,
}) {
  return ProgramExercise(
    metadata: _metadata(id),
    programId: programId,
    sourceExerciseId: sourceExerciseId,
    position: position,
    exerciseNameSnapshot: 'Exercise $id',
    availableTimeSnapshot: false,
    availableRepsSnapshot: true,
    availableScoreSnapshot: false,
    setsCount: 3,
    timeEnabled: false,
    repsEnabled: true,
    scoreEnabled: false,
    targetReps: 10,
  );
}

WorkoutTemplateProgram _templateProgram({
  required String id,
  required String workoutTemplateId,
  required String sourceProgramId,
  required int position,
}) {
  return WorkoutTemplateProgram(
    metadata: _metadata(id),
    workoutTemplateId: workoutTemplateId,
    sourceProgramId: sourceProgramId,
    position: position,
    programNameSnapshot: 'Program $id',
    defaultRestSecondsSnapshot: 60,
    programSnapshotJson: '{"exercises":[]}',
  );
}

final class _FakeExerciseRepository implements ExerciseRepository {
  Exercise? exercise;
  final saved = <Exercise>[];

  @override
  Future<Exercise?> findById(String id) async {
    return exercise?.metadata.id == id ? exercise : null;
  }

  @override
  Future<bool> isReferencedByProgram(String id) async => false;

  @override
  Future<List<Exercise>> listActive() async =>
      exercise == null || exercise!.metadata.deletedAt != null
      ? const []
      : [exercise!];

  @override
  Future<void> save(Exercise exercise) async {
    this.exercise = exercise;
    saved.add(exercise);
  }
}

LocalDataExportSnapshot _backupSnapshot({
  DateTime? exportedAt,
  List<LocalBackupResource> mediaAssets = const [],
  List<LocalBackupResource> exercises = const [],
  List<LocalBackupResource> programs = const [],
  List<LocalBackupResource> workoutTemplates = const [],
  List<LocalBackupResource> workoutHistories = const [],
  List<EmbeddedBackupMediaFile> mediaFiles = const [],
}) {
  return LocalDataExportSnapshot(
    exportedAt: exportedAt ?? DateTime.utc(2026, 7, 22, 12),
    appSchemaVersion: 19,
    originDeviceId: 'device-1',
    mediaAssets: mediaAssets,
    exercises: exercises,
    programs: programs,
    workoutTemplates: workoutTemplates,
    workoutHistories: workoutHistories,
    mediaFiles: mediaFiles,
  );
}

LocalBackupResource _backupResource({
  required String id,
  required DateTime updatedAt,
  required Map<String, Object?> payload,
}) {
  return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload);
}

Map<String, Object?> _backupMetadata(String id, DateTime updatedAt) => {
  'id': id,
  'createdAt': updatedAt.toUtc().toIso8601String(),
  'updatedAt': updatedAt.toUtc().toIso8601String(),
  'deletedAt': null,
  'schemaVersion': 1,
  'syncState': 'synced',
  'localRevision': 0,
  'originDeviceId': 'device-1',
};

final class _FakeLocalDataBackupRepository
    implements LocalDataBackupRepository {
  _FakeLocalDataBackupRepository({
    LocalDataExportSnapshot? snapshot,
    this.hasOpenSession = false,
  }) : snapshot = snapshot ?? _backupSnapshot();

  final LocalDataExportSnapshot snapshot;
  final bool hasOpenSession;
  final appliedModes = <LocalBackupImportMode>[];
  final appliedSnapshots = <LocalDataExportSnapshot>[];

  @override
  Future<LocalBackupImportResult> applyImportSnapshot({
    required LocalDataExportSnapshot snapshot,
    required LocalBackupImportMode mode,
    required DateTime importedAt,
  }) async {
    appliedModes.add(mode);
    appliedSnapshots.add(snapshot);
    return const LocalBackupImportResult(
      insertedCount: 0,
      updatedCount: 0,
      ignoredOlderCount: 0,
      deletedByReplaceCount: 0,
      missingMediaCount: 0,
    );
  }

  @override
  Future<bool> hasAnyUserData() async => false;

  @override
  Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;

  @override
  Future<LocalDataExportSnapshot> readExportSnapshot(
    DateTime exportedAt,
  ) async {
    return snapshot.copyWith(mediaFiles: snapshot.mediaFiles);
  }
}

final class _ThrowingLocalDataBackupRepository
    implements LocalDataBackupRepository {
  @override
  Future<LocalBackupImportResult> applyImportSnapshot({
    required LocalDataExportSnapshot snapshot,
    required LocalBackupImportMode mode,
    required DateTime importedAt,
  }) async {
    throw Exception('transaction failed');
  }

  @override
  Future<bool> hasAnyUserData() async => false;

  @override
  Future<bool> hasOpenActiveWorkoutSession() async => false;

  @override
  Future<LocalDataExportSnapshot> readExportSnapshot(
    DateTime exportedAt,
  ) async {
    return _backupSnapshot();
  }
}

final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
  _FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}});

  final List<EmbeddedBackupMediaFile> files;
  final Map<String, RestoredMediaFile> restored;
  final requestedMediaIds = <String>[];

  @override
  Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
    List<LocalBackupResource> mediaAssets,
  ) async {
    requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id));
    return files;
  }

  @override
  Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
    List<EmbeddedBackupMediaFile> files,
  ) async {
    return restored;
  }
}

final class _FakeWorkoutTemplateRepository
    implements WorkoutTemplateRepository {
  final templates = <WorkoutTemplate>[];

  @override
  Future<WorkoutTemplate?> findById(String id) async {
    for (final template in templates) {
      if (template.metadata.id == id && template.metadata.deletedAt == null) {
        return template;
      }
    }
    return null;
  }

  @override
  Future<List<WorkoutTemplate>> listActive() async {
    return templates
        .where((template) => template.metadata.deletedAt == null)
        .toList();
  }

  @override
  Future<void> replaceComposition(
    WorkoutTemplate template,
    DateTime deletedAt,
  ) async {
    await save(template);
  }

  @override
  Future<void> save(WorkoutTemplate template) async {
    templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
    templates.add(template);
  }

  @override
  Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}

  @override
  Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}

final class _FakeProgramRepository implements ProgramRepository {
  final programs = <Program>[];

  @override
  Future<Program?> findById(String id) async {
    for (final program in programs) {
      if (program.metadata.id == id && program.metadata.deletedAt == null) {
        return program;
      }
    }
    return null;
  }

  @override
  Future<List<Program>> listActive() async {
    return programs
        .where((program) => program.metadata.deletedAt == null)
        .toList();
  }

  @override
  Future<void> replaceExercises(Program program, DateTime deletedAt) async {
    await save(program);
  }

  @override
  Future<void> save(Program program) async {
    programs.removeWhere((saved) => saved.metadata.id == program.metadata.id);
    programs.add(program);
  }

  @override
  Future<void> saveExercise(ProgramExercise exercise) async {
    final index = programs.indexWhere(
      (program) => program.metadata.id == exercise.programId,
    );
    if (index == -1) {
      return;
    }
    final program = programs[index];
    programs[index] = program.copyWith(
      exercises: [
        ...program.exercises.where(
          (saved) => saved.metadata.id != exercise.metadata.id,
        ),
        exercise,
      ],
    );
  }
}

ActiveWorkoutSessionUseCases _activeUseCase(
  _FakeActiveSessionRepository repository,
  _FakeClock clock,
) {
  return ActiveWorkoutSessionUseCases(
    sessionRepository: repository,
    templateRepository: _FakeWorkoutTemplateRepository(),
    clock: clock,
    ids: _FakeIds(),
    originDeviceId: 'device-1',
  );
}

final class _FakeActiveSessionRepository implements ActiveSessionRepository {
  ActiveWorkoutSession? session;
  final results = <ActiveSetResult>[];
  final restStates = <String, ActiveRestState>{};
  final setTimerStates = <String, ActiveSetTimerState>{};
  final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
  final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
  final stepResults = <ActiveExerciseStepResult>[];

  @override
  Future<ActiveWorkoutSession?> findById(String id) async {
    return session?.metadata.id == id ? session : null;
  }

  @override
  Future<ActiveWorkoutSession?> findOpen() async => session;

  @override
  Future<ActiveRestState?> findRestStateById(String id) async => restStates[id];

  @override
  Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    return scoreStopwatchStates.values
        .where(
          (state) =>
              state.activeWorkoutSessionId == sessionId &&
              state.programIndex == programIndex &&
              state.exerciseIndex == exerciseIndex &&
              state.setIndex == setIndex &&
              state.metadata.deletedAt == null,
        )
        .firstOrNull;
  }

  @override
  Future<ActiveSetTimerState?> findSetTimerState({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    return setTimerStates.values
        .where(
          (state) =>
              state.activeWorkoutSessionId == sessionId &&
              state.programIndex == programIndex &&
              state.exerciseIndex == exerciseIndex &&
              state.setIndex == setIndex &&
              state.metadata.deletedAt == null,
        )
        .firstOrNull;
  }

  @override
  Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
  }) async {
    return stepProgressStates.values
        .where(
          (state) =>
              state.activeWorkoutSessionId == sessionId &&
              state.programIndex == programIndex &&
              state.exerciseIndex == exerciseIndex &&
              state.setIndex == setIndex &&
              state.metadata.deletedAt == null,
        )
        .firstOrNull;
  }

  @override
  Future<List<ActiveRestState>> listRestStates(String sessionId) async {
    return restStates.values
        .where((rest) => rest.activeWorkoutSessionId == sessionId)
        .toList();
  }

  @override
  Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
    String sessionId,
  ) async {
    return scoreStopwatchStates.values
        .where(
          (state) =>
              state.activeWorkoutSessionId == sessionId &&
              state.metadata.deletedAt == null,
        )
        .toList();
  }

  @override
  Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
    return results
        .where((result) => result.activeWorkoutSessionId == sessionId)
        .toList();
  }

  @override
  Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
    return setTimerStates.values
        .where(
          (state) =>
              state.activeWorkoutSessionId == sessionId &&
              state.metadata.deletedAt == null,
        )
        .toList();
  }

  @override
  Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
    String sessionId,
  ) async {
    return stepProgressStates.values
        .where((state) => state.activeWorkoutSessionId == sessionId)
        .toList();
  }

  @override
  Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
    String sessionId,
  ) async {
    return stepResults
        .where((result) => result.activeWorkoutSessionId == sessionId)
        .toList();
  }

  @override
  Future<void> save(ActiveWorkoutSession session) async {
    this.session = session;
  }

  @override
  Future<void> saveRestState(ActiveRestState restState) async {
    restStates[restState.metadata.id] = restState;
  }

  @override
  Future<void> saveSetTimerState(ActiveSetTimerState state) async {
    setTimerStates[state.metadata.id] = state;
  }

  @override
  Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
    scoreStopwatchStates[state.metadata.id] = state;
  }

  @override
  Future<void> saveExerciseStepProgressState(
    ActiveExerciseStepProgressState state,
  ) async {
    stepProgressStates[state.metadata.id] = state;
  }

  @override
  Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
    stepResults.removeWhere(
      (existing) =>
          existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
          existing.programIndex == result.programIndex &&
          existing.exerciseIndex == result.exerciseIndex &&
          existing.setIndex == result.setIndex &&
          existing.passageIndex == result.passageIndex &&
          existing.stepIndex == result.stepIndex,
    );
    stepResults.add(result);
  }

  @override
  Future<void> deleteScoreStopwatchState({
    required String sessionId,
    required int programIndex,
    required int exerciseIndex,
    required int setIndex,
    required DateTime deletedAt,
  }) async {
    final state = await findScoreStopwatchState(
      sessionId: sessionId,
      programIndex: programIndex,
      exerciseIndex: exerciseIndex,
      setIndex: setIndex,
    );
    if (state == null) {
      return;
    }
    scoreStopwatchStates.remove(state.metadata.id);
  }

  @override
  Future<void> saveSetResult(ActiveSetResult result) async {
    results.removeWhere(
      (existing) =>
          existing.activeWorkoutSessionId == result.activeWorkoutSessionId &&
          existing.programIndex == result.programIndex &&
          existing.exerciseIndex == result.exerciseIndex &&
          existing.setIndex == result.setIndex,
    );
    results.add(result);
  }
}

ActiveWorkoutSession _sessionWithSnapshot({
  required int currentProgramIndex,
  required int currentExerciseIndex,
  required int currentSetIndex,
  int setsCount = 2,
  int targetReps = 10,
  bool timeEnabled = false,
  bool repsEnabled = true,
  bool scoreEnabled = false,
  int? targetTimeSeconds,
  ScoreInputMode scoreInputMode = ScoreInputMode.manual,
  int? targetScoreTimeMs,
  List<ExerciseStep> exerciseSteps = const [],
  bool? autoStartNextTimedStepSnapshot = true,
  bool? autoStartNextTimedStepOverride,
  bool? templateAutoStartNextTimedStepOverride,
}) {
  final exerciseSnapshot = {
    'id': 'exercise-snapshot-1',
    'exerciseNameSnapshot': 'Squat',
    'setsCount': setsCount,
    'timeEnabled': timeEnabled,
    'repsEnabled': repsEnabled,
    'scoreEnabled': scoreEnabled,
    'targetTimeSeconds': targetTimeSeconds,
    'targetReps': repsEnabled ? targetReps : null,
    'targetScoreTimeMs': targetScoreTimeMs,
    'scoreInputModeSnapshot': scoreInputMode.name,
    'exerciseStepsSnapshot': exerciseSteps
        .map((step) => step.toSnapshotJson())
        .toList(),
    'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot,
    'autoStartNextTimedStepOverride': ?autoStartNextTimedStepOverride,
  };
  return ActiveWorkoutSession(
    metadata: _metadata('session-1'),
    status: ActiveWorkoutStatus.running,
    startedAt: DateTime.utc(2026, 7, 17, 12),
    lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
    elapsedActiveMs: 0,
    currentProgramIndex: currentProgramIndex,
    currentExerciseIndex: currentExerciseIndex,
    currentSetIndex: currentSetIndex,
    resolvedTemplateSnapshotJson: jsonEncode({
      'programs': [
        {
          'id': 'program-snapshot-1',
          'programNameSnapshot': 'Programme',
          'programSnapshotJson': jsonEncode({
            'exercises': [exerciseSnapshot],
          }),
        },
      ],
      'overrides': [
        if (templateAutoStartNextTimedStepOverride != null)
          {
            'workoutTemplateProgramId': 'program-snapshot-1',
            'snapshotProgramExerciseId': 'exercise-snapshot-1',
            'autoStartNextTimedStepOverride':
                templateAutoStartNextTimedStepOverride,
          },
      ],
    }),
  );
}

List<ExerciseStep> _twoTimedSteps() {
  return [
    _step(
      id: 'step-1',
      position: 0,
      name: 'Gauche',
      type: ExerciseStepType.time,
      defaultTargetValue: 1,
    ),
    _step(
      id: 'step-2',
      position: 1,
      name: 'Droite',
      type: ExerciseStepType.time,
      defaultTargetValue: 1,
    ),
  ];
}
