import 'dart:convert';

import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/local/local.dart' as local;

void main() {
  late local.AppDatabase database;
  late local.DriftExerciseRepository exerciseRepository;
  late local.DriftProgramRepository programRepository;
  late local.DriftActiveSessionRepository activeRepository;
  late local.DriftWorkoutTemplateRepository templateRepository;
  late local.DriftWorkoutHistoryRepository historyRepository;
  late local.DriftProgressionStatsRepository progressionStatsRepository;
  late local.DriftLocalSyncChangeRepository syncChangeRepository;
  late local.DriftLocalDataBackupRepository localDataBackupRepository;
  late local.DriftExercisePerformanceReferenceRepository
  performanceReferenceRepository;

  setUp(() {
    database = local.AppDatabase(NativeDatabase.memory());
    exerciseRepository = local.DriftExerciseRepository(database);
    programRepository = local.DriftProgramRepository(database);
    activeRepository = local.DriftActiveSessionRepository(database);
    templateRepository = local.DriftWorkoutTemplateRepository(database);
    historyRepository = local.DriftWorkoutHistoryRepository(database);
    progressionStatsRepository = local.DriftProgressionStatsRepository(
      database,
    );
    syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
    localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
    performanceReferenceRepository =
        local.DriftExercisePerformanceReferenceRepository(database);
  });

  tearDown(() async {
    await database.close();
  });

  test('exercise mutations are written to change log', () async {
    final now = DateTime.utc(2026, 7, 17, 12);
    final exercise = Exercise(
      metadata: _metadata('exercise-1', now),
      name: 'Squat',
      hasTimeMeasure: false,
      hasRepsMeasure: true,
      hasScoreMeasure: false,
      defaultTargetReps: 10,
    );

    await exerciseRepository.save(exercise);
    await exerciseRepository.save(
      exercise.copyWith(
        metadata: _metadata(
          'exercise-1',
          now.add(const Duration(seconds: 1)),
          1,
        ),
        name: 'Front squat',
      ),
    );
    await exerciseRepository.save(
      exercise
          .copyWith(
            metadata: _metadata(
              'exercise-1',
              now.add(const Duration(seconds: 2)),
              2,
            ),
            name: 'Front squat',
          )
          .archive(now.add(const Duration(seconds: 2))),
    );

    final changes =
        await (database.select(database.changeLogEntries)
              ..where((table) => table.entityId.equals('exercise-1'))
              ..orderBy([
                (table) => drift.OrderingTerm.asc(table.localRevision),
              ]))
            .get();

    expect(changes.map((change) => change.operation), [
      'insert',
      'update',
      'update',
    ]);
    expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
  });

  test('exercise repository accepts zero manual default score', () async {
    final exercise = Exercise(
      metadata: _metadata('exercise-score-zero', DateTime.utc(2026, 7, 17, 12)),
      name: 'Score nul',
      hasTimeMeasure: false,
      hasRepsMeasure: false,
      hasScoreMeasure: true,
      scoreLabel: 'Score',
      scoreUnit: 'pts',
      defaultTargetScore: 0,
    );

    await exerciseRepository.save(exercise);

    final restored = await exerciseRepository.findById(exercise.metadata.id);
    expect(restored, isNotNull);
    expect(restored!.defaultTargetScore, 0);
  });

  test('taggable tables expose tags json columns on fresh schema', () async {
    Future<List<String>> columnNames(String tableName) async {
      final rows = await database
          .customSelect('PRAGMA table_info($tableName)')
          .get();
      return rows.map((row) => row.data['name'] as String).toList();
    }

    expect(await columnNames('exercises'), contains('tags_json'));
    expect(await columnNames('programs'), contains('tags_json'));
    expect(await columnNames('workout_templates'), contains('tags_json'));
    expect(database.schemaVersion, 19);
  });

  test('repositories save and load normalized tags', () async {
    final now = DateTime.utc(2026, 7, 22, 10);
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata('exercise-tags', now),
        name: 'Shoot',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        tags: const [' Match ', 'Extérieur'],
      ),
    );
    await programRepository.save(
      Program(
        metadata: _metadata('program-tags', now),
        name: 'Program',
        defaultRestSeconds: 45,
        tags: const ['Intense'],
      ),
    );
    await templateRepository.save(
      WorkoutTemplate(
        metadata: _metadata('template-tags', now),
        name: 'Template',
        tags: const ['Routine'],
      ),
    );

    final exercise = await exerciseRepository.findById('exercise-tags');
    final program = await programRepository.findById('program-tags');
    final template = await templateRepository.findById('template-tags');

    expect(exercise!.tags, ['match', 'extérieur']);
    expect(program!.tags, ['intense']);
    expect(template!.tags, ['routine']);
  });

  test('local sync payload includes tags for taggable resources', () async {
    final now = DateTime.utc(2026, 7, 22, 10, 30);
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata('exercise-sync-tags', now),
        name: 'Shoot',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        tags: const ['match'],
      ),
    );
    await programRepository.save(
      Program(
        metadata: _metadata('program-sync-tags', now),
        name: 'Program',
        defaultRestSeconds: 45,
        tags: const ['intense'],
      ),
    );
    await templateRepository.save(
      WorkoutTemplate(
        metadata: _metadata('template-sync-tags', now),
        name: 'Template',
        tags: const ['routine'],
      ),
    );

    final changes = await syncChangeRepository.listPendingChanges();
    final payloadsById = {
      for (final change in changes) change.item.clientId: change.item.payload,
    };

    expect(payloadsById['exercise-sync-tags']!['tags'], ['match']);
    expect(payloadsById['program-sync-tags']!['tags'], ['intense']);
    expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
  });

  test('local sync pull defaults missing tags to empty lists', () async {
    final now = DateTime.utc(2026, 7, 22, 11);
    await syncChangeRepository.applyRemoteItem(
      RemoteSyncedItem(
        resourceType: SyncResourceType.exercise,
        clientId: 'remote-exercise-no-tags',
        serverId: 'server-exercise-no-tags',
        schemaVersion: 1,
        clientUpdatedAt: now,
        serverUpdatedAt: now,
        deletedAt: null,
        payload: const {
          'id': 'remote-exercise-no-tags',
          'name': 'Remote exercise',
          'hasTimeMeasure': false,
          'hasRepsMeasure': true,
          'hasScoreMeasure': false,
        },
      ),
    );
    await syncChangeRepository.applyRemoteItem(
      RemoteSyncedItem(
        resourceType: SyncResourceType.program,
        clientId: 'remote-program-no-tags',
        serverId: 'server-program-no-tags',
        schemaVersion: 1,
        clientUpdatedAt: now,
        serverUpdatedAt: now,
        deletedAt: null,
        payload: const {
          'id': 'remote-program-no-tags',
          'name': 'Remote program',
          'defaultRestSeconds': 30,
        },
      ),
    );
    await syncChangeRepository.applyRemoteItem(
      RemoteSyncedItem(
        resourceType: SyncResourceType.workoutTemplate,
        clientId: 'remote-template-no-tags',
        serverId: 'server-template-no-tags',
        schemaVersion: 1,
        clientUpdatedAt: now,
        serverUpdatedAt: now,
        deletedAt: null,
        payload: const {
          'id': 'remote-template-no-tags',
          'name': 'Remote template',
        },
      ),
    );

    final exercise = await exerciseRepository.findById(
      'remote-exercise-no-tags',
    );
    final program = await programRepository.findById('remote-program-no-tags');
    final template = await templateRepository.findById(
      'remote-template-no-tags',
    );

    expect(exercise!.tags, isEmpty);
    expect(program!.tags, isEmpty);
    expect(template!.tags, isEmpty);
  });

  test('local backup export includes tags and full workout history', () async {
    final now = DateTime.utc(2026, 7, 22, 10);
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata('backup-exercise', now),
        name: 'Backup Shoot',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
        tags: const ['match'],
      ),
    );
    await historyRepository.save(
      WorkoutHistory(
        metadata: _metadata('backup-history', now),
        nameSnapshot: 'Backup Session',
        startedAt: now,
        endedAt: now.add(const Duration(minutes: 10)),
        totalActiveMs: 600000,
        completed: true,
        historySnapshotJson: '{"name":"Backup Session"}',
        results: [
          _historySetResult(
            id: 'backup-result',
            historyId: 'backup-history',
            sourceExerciseId: 'backup-exercise',
            setIndex: 0,
            startedAt: now,
            actualReps: 12,
          ),
        ],
        stepResults: [
          _historyStepResult(
            id: 'backup-step-result',
            historyId: 'backup-history',
            sourceExerciseId: 'backup-exercise',
            startedAt: now,
          ),
        ],
      ),
    );

    final snapshot = await localDataBackupRepository.readExportSnapshot(
      DateTime.utc(2026, 7, 22, 12),
    );
    final document =
        jsonDecode(utf8.decode(const LocalBackupCodec().encode(snapshot)))
            as Map<String, Object?>;
    final data = document['data'] as Map<String, Object?>;
    final exercises = data['exercises'] as List;
    final histories = data['workoutHistories'] as List;
    final history = histories.single as Map;

    expect(document['kind'], 'gametime.localBackup');
    expect(exercises.single, containsPair('tags', ['match']));
    expect(history['results'], hasLength(1));
    expect(history['stepResults'], hasLength(1));
  });

  test('local backup merge applies LWW by type and stable id', () async {
    final localTime = DateTime.utc(2026, 7, 22, 10);
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata('merge-existing', localTime),
        name: 'Local older',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 8,
      ),
    );
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata(
          'merge-local-newer',
          localTime.add(const Duration(hours: 3)),
        ),
        name: 'Local newer',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 8,
      ),
    );
    final snapshot = LocalDataExportSnapshot(
      exportedAt: DateTime.utc(2026, 7, 22, 12),
      appSchemaVersion: 19,
      originDeviceId: 'device-backup',
      mediaAssets: const [],
      exercises: [
        _backupExerciseResource(
          id: 'merge-existing',
          name: 'Backup newer',
          updatedAt: localTime.add(const Duration(hours: 2)),
        ),
        _backupExerciseResource(
          id: 'merge-local-newer',
          name: 'Backup older',
          updatedAt: localTime.add(const Duration(hours: 1)),
        ),
        _backupExerciseResource(
          id: 'merge-new',
          name: 'Backup inserted',
          updatedAt: localTime.add(const Duration(hours: 1)),
        ),
      ],
      programs: const [],
      workoutTemplates: const [],
      workoutHistories: const [],
    );

    final result = await localDataBackupRepository.applyImportSnapshot(
      snapshot: snapshot,
      mode: LocalBackupImportMode.merge,
      importedAt: DateTime.utc(2026, 7, 22, 14),
    );

    expect(result.insertedCount, 1);
    expect(result.updatedCount, 1);
    expect(result.ignoredOlderCount, 1);
    expect(
      (await exerciseRepository.findById('merge-existing'))!.name,
      'Backup newer',
    );
    expect(
      (await exerciseRepository.findById('merge-local-newer'))!.name,
      'Local newer',
    );
    expect(
      (await exerciseRepository.findById('merge-new'))!.name,
      'Backup inserted',
    );
  });

  test(
    'local backup replaceAll soft deletes absent data and imports file',
    () async {
      final now = DateTime.utc(2026, 7, 22, 10);
      await exerciseRepository.save(
        Exercise(
          metadata: _metadata('replace-old', now),
          name: 'Old',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
          defaultTargetReps: 8,
        ),
      );
      final snapshot = LocalDataExportSnapshot(
        exportedAt: DateTime.utc(2026, 7, 22, 12),
        appSchemaVersion: 19,
        originDeviceId: 'device-backup',
        mediaAssets: const [],
        exercises: [
          _backupExerciseResource(
            id: 'replace-new',
            name: 'New',
            updatedAt: now.add(const Duration(hours: 1)),
          ),
        ],
        programs: const [],
        workoutTemplates: const [],
        workoutHistories: const [],
      );

      final result = await localDataBackupRepository.applyImportSnapshot(
        snapshot: snapshot,
        mode: LocalBackupImportMode.replaceAll,
        importedAt: DateTime.utc(2026, 7, 22, 14),
      );

      expect(result.deletedByReplaceCount, 1);
      expect(await exerciseRepository.listActive(), hasLength(1));
      expect((await exerciseRepository.findById('replace-new'))!.name, 'New');
      final tombstones =
          await (database.select(database.changeLogEntries)..where(
                (table) =>
                    table.entityId.equals('replace-old') &
                    table.operation.equals('softDelete'),
              ))
              .get();
      expect(tombstones, hasLength(1));
    },
  );

  test('local backup import is blocked by open active workout', () async {
    final now = DateTime.utc(2026, 7, 22, 10);
    await activeRepository.save(
      ActiveWorkoutSession(
        metadata: _metadata('active-import-block', now),
        status: ActiveWorkoutStatus.running,
        startedAt: now,
        lastPersistedAt: now,
        elapsedActiveMs: 0,
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        resolvedTemplateSnapshotJson: '{"programs":[]}',
      ),
    );

    await expectLater(
      localDataBackupRepository.applyImportSnapshot(
        snapshot: LocalDataExportSnapshot(
          exportedAt: now,
          appSchemaVersion: 19,
          originDeviceId: 'device-backup',
          mediaAssets: const [],
          exercises: const [],
          programs: const [],
          workoutTemplates: const [],
          workoutHistories: const [],
        ),
        mode: LocalBackupImportMode.merge,
        importedAt: now.add(const Duration(hours: 1)),
      ),
      throwsA(
        isA<LocalBackupException>().having(
          (error) => error.error,
          'error',
          LocalBackupValidationError.activeWorkoutInProgress,
        ),
      ),
    );
  });

  test('program duplication persists copied children through Drift', () async {
    final now = DateTime.utc(2026, 7, 22, 11, 15);
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata('exercise-dup-source', now),
        name: 'Shoot',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
      ),
    );
    await programRepository.replaceExercises(
      Program(
        metadata: _metadata('program-dup-source', now),
        name: 'Programme tirs',
        defaultRestSeconds: 30,
        isExample: true,
        tags: const ['match'],
        exercises: [
          _programExercise(
            'program-exercise-dup-source',
            now,
            programId: 'program-dup-source',
            position: 0,
          ),
        ],
      ),
      now,
    );
    final useCase = ProgramUseCases(
      programRepository: programRepository,
      exerciseRepository: exerciseRepository,
      templateRepository: templateRepository,
      clock: _FakeClock(now.add(const Duration(minutes: 1))),
      ids: _FakeIds(),
      originDeviceId: 'device-1',
    );

    final copy = await useCase.duplicate('program-dup-source');
    final restored = await programRepository.findById(copy.metadata.id);

    expect(restored, isNotNull);
    expect(restored!.name, 'Copie de Programme tirs');
    expect(restored.isExample, isFalse);
    expect(restored.tags, ['match']);
    expect(
      restored.exercises.single.metadata.id,
      isNot('program-exercise-dup-source'),
    );
    expect(restored.exercises.single.programId, restored.metadata.id);
    expect(restored.exercises.single.exerciseNameSnapshot, 'Exercise 0');
  });

  test(
    'workout template duplication persists remapped overrides through Drift',
    () async {
      final now = DateTime.utc(2026, 7, 22, 11, 30);
      await programRepository.save(
        Program(
          metadata: _metadata('program-template-source', now),
          name: 'Program source',
          defaultRestSeconds: 30,
        ),
      );
      await templateRepository.replaceComposition(
        WorkoutTemplate(
          metadata: _metadata('template-dup-source', now),
          name: 'Prépa match',
          lastStartedAt: now,
          isExample: true,
          tags: const ['intense'],
          programs: [
            WorkoutTemplateProgram(
              metadata: _metadata('template-program-dup-source', now),
              workoutTemplateId: 'template-dup-source',
              sourceProgramId: 'program-template-source',
              position: 0,
              programNameSnapshot: 'Program source',
              defaultRestSecondsSnapshot: 30,
              programSnapshotJson: '{"exercises":[]}',
            ),
          ],
          overrides: [
            WorkoutTemplateExerciseOverride(
              metadata: _metadata('template-override-dup-source', now),
              workoutTemplateProgramId: 'template-program-dup-source',
              snapshotProgramExerciseId: 'snapshot-exercise',
              setsCountOverride: 4,
            ),
          ],
        ),
        now,
      );
      final useCase = WorkoutTemplateUseCases(
        templateRepository: templateRepository,
        programRepository: programRepository,
        clock: _FakeClock(now.add(const Duration(minutes: 1))),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final copy = await useCase.duplicate('template-dup-source');
      final restored = await templateRepository.findById(copy.metadata.id);

      expect(restored, isNotNull);
      expect(restored!.name, 'Copie de Prépa match');
      expect(restored.lastStartedAt, isNull);
      expect(restored.isExample, isFalse);
      expect(restored.tags, ['intense']);
      expect(
        restored.programs.single.metadata.id,
        isNot('template-program-dup-source'),
      );
      expect(restored.programs.single.workoutTemplateId, restored.metadata.id);
      expect(
        restored.overrides.single.metadata.id,
        isNot('template-override-dup-source'),
      );
      expect(
        restored.overrides.single.workoutTemplateProgramId,
        restored.programs.single.metadata.id,
      );
      expect(
        restored.overrides.single.snapshotProgramExerciseId,
        'snapshot-exercise',
      );
    },
  );

  test('starter seed populates a fresh database once', () async {
    final seedRepository = local.DriftStarterSeedRepository(database);
    final result = await SeedStarterContentUseCase(
      seedStateRepository: seedRepository,
      contentRepository: seedRepository,
      clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
      originDeviceId: 'local-device',
    ).run();

    expect(result.status, StarterSeedStatus.inserted);
    expect(await seedRepository.readAppliedStarterSeedVersion(), 1);

    final exercises = await exerciseRepository.listActive();
    final programs = await programRepository.listActive();
    final templates = await templateRepository.listActive();
    expect(exercises, hasLength(21));
    expect(programs, hasLength(1));
    expect(templates, hasLength(1));
    expect(exercises.every((exercise) => exercise.isExample), isTrue);
    expect(programs.single.isExample, isTrue);
    expect(templates.single.isExample, isTrue);
    expect(exercises.map((exercise) => exercise.category).toSet(), {
      ExerciseCategory.shoot,
      ExerciseCategory.freeThrows,
      ExerciseCategory.dribble,
      ExerciseCategory.finishing,
      ExerciseCategory.conditioning,
      ExerciseCategory.defense,
      ExerciseCategory.mobility,
    });

    final secondRun = await SeedStarterContentUseCase(
      seedStateRepository: seedRepository,
      contentRepository: seedRepository,
      clock: _FakeClock(DateTime.utc(2026, 7, 21, 9)),
      originDeviceId: 'local-device',
    ).run();
    expect(secondRun.status, StarterSeedStatus.skippedAlreadyApplied);
    expect(await exerciseRepository.listActive(), hasLength(21));
  });

  test(
    'starter seed marks non-empty database without inserting examples',
    () async {
      final now = DateTime.utc(2026, 7, 21, 8);
      await exerciseRepository.save(
        Exercise(
          metadata: _metadata('user-exercise-1', now),
          name: 'Exercice utilisateur',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
          defaultTargetReps: 10,
        ),
      );
      final seedRepository = local.DriftStarterSeedRepository(database);

      final result = await SeedStarterContentUseCase(
        seedStateRepository: seedRepository,
        contentRepository: seedRepository,
        clock: _FakeClock(now),
        originDeviceId: 'local-device',
      ).run();

      expect(result.status, StarterSeedStatus.skippedNotEmpty);
      expect(await seedRepository.readAppliedStarterSeedVersion(), 1);
      expect(await exerciseRepository.listActive(), hasLength(1));
      expect(await programRepository.listActive(), isEmpty);
      expect(await templateRepository.listActive(), isEmpty);
    },
  );

  test('starter seed does not reappear after example deletion', () async {
    final seedRepository = local.DriftStarterSeedRepository(database);
    await SeedStarterContentUseCase(
      seedStateRepository: seedRepository,
      contentRepository: seedRepository,
      clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
      originDeviceId: 'local-device',
    ).run();

    final exercise = (await exerciseRepository.listActive()).first;
    await exerciseRepository.save(
      exercise.copyWith(
        metadata: exercise.metadata.markDeleted(DateTime.utc(2026, 7, 21, 9)),
      ),
    );
    await SeedStarterContentUseCase(
      seedStateRepository: seedRepository,
      contentRepository: seedRepository,
      clock: _FakeClock(DateTime.utc(2026, 7, 21, 10)),
      originDeviceId: 'local-device',
    ).run();

    expect(await exerciseRepository.listActive(), hasLength(20));
    expect(await exerciseRepository.findById(exercise.metadata.id), isNotNull);
  });

  test(
    'starter program and workout template snapshot exercise steps',
    () async {
      final seedRepository = local.DriftStarterSeedRepository(database);
      await SeedStarterContentUseCase(
        seedStateRepository: seedRepository,
        contentRepository: seedRepository,
        clock: _FakeClock(DateTime.utc(2026, 7, 21, 8)),
        originDeviceId: 'local-device',
      ).run();

      final program = (await programRepository.listActive()).single;
      expect(program.name, 'Fondations basket - 45 min');
      expect(program.exercises, hasLength(8));
      expect(
        program.exercises
            .map((exercise) => exercise.setsCount)
            .reduce((total, count) => total + count),
        22,
      );
      final spotShooting = program.exercises.singleWhere(
        (exercise) =>
            exercise.exerciseNameSnapshot == 'Spot shooting 5 positions',
      );
      expect(spotShooting.exerciseStepsSnapshot, hasLength(5));
      expect(spotShooting.exerciseStepsSnapshot.map((step) => step.name), [
        'Coin droit',
        'Aile droite',
        'Face cercle',
        'Aile gauche',
        'Coin gauche',
      ]);

      final template = (await templateRepository.listActive()).single;
      expect(template.name, 'Séance exemple - Fondations basket');
      expect(template.programs, hasLength(1));
      expect(template.programs.single.sourceProgramId, program.metadata.id);
      final snapshot =
          jsonDecode(template.programs.single.programSnapshotJson)
              as Map<String, Object?>;
      expect(snapshot['programId'], program.metadata.id);
      expect(snapshot['name'], program.name);
      expect(snapshot['exercises'], isA<List<Object?>>());
      expect(snapshot['exercises'] as List<Object?>, hasLength(8));
    },
  );

  test(
    'step chaining settings round-trip through drift repositories',
    () async {
      final now = DateTime.utc(2026, 7, 17, 12);
      final exercise = Exercise(
        metadata: _metadata('exercise-chain-1', now),
        name: 'Circuit',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 10,
        autoStartNextTimedStep: false,
      );
      await exerciseRepository.save(exercise);

      final restoredExercise = await exerciseRepository.findById(
        exercise.metadata.id,
      );
      expect(restoredExercise!.autoStartNextTimedStep, isFalse);

      final programExercise = _programExercise(
        'program-exercise-chain-1',
        now,
        programId: 'program-chain-1',
        position: 0,
        autoStartNextTimedStepSnapshot: false,
        autoStartNextTimedStepOverride: true,
      );
      await programRepository.save(
        Program(
          metadata: _metadata('program-chain-1', now),
          name: 'Programme',
          defaultRestSeconds: 60,
          exercises: [programExercise],
        ),
      );

      final restoredProgram = await programRepository.findById(
        'program-chain-1',
      );
      expect(
        restoredProgram!.exercises.single.autoStartNextTimedStepSnapshot,
        isFalse,
      );
      expect(
        restoredProgram.exercises.single.autoStartNextTimedStepOverride,
        isTrue,
      );

      final templateProgram = WorkoutTemplateProgram(
        metadata: _metadata('template-program-chain-1', now),
        workoutTemplateId: 'template-chain-1',
        sourceProgramId: 'program-chain-1',
        position: 0,
        programNameSnapshot: 'Programme',
        defaultRestSecondsSnapshot: 60,
        programSnapshotJson: jsonEncode({
          'exercises': [programExercise.toSnapshotJson()],
        }),
      );
      await templateRepository.save(
        WorkoutTemplate(
          metadata: _metadata('template-chain-1', now),
          name: 'Séance',
          programs: [templateProgram],
          overrides: [
            WorkoutTemplateExerciseOverride(
              metadata: _metadata('override-chain-1', now),
              workoutTemplateProgramId: templateProgram.metadata.id,
              snapshotProgramExerciseId: programExercise.metadata.id,
            ),
            WorkoutTemplateExerciseOverride(
              metadata: _metadata('override-chain-2', now),
              workoutTemplateProgramId: templateProgram.metadata.id,
              snapshotProgramExerciseId: 'program-exercise-chain-2',
              autoStartNextTimedStepOverride: false,
            ),
          ],
        ),
      );

      final restoredTemplate = await templateRepository.findById(
        'template-chain-1',
      );
      expect(
        restoredTemplate!.overrides
            .singleWhere(
              (override) => override.metadata.id == 'override-chain-1',
            )
            .autoStartNextTimedStepOverride,
        isNull,
      );
      expect(
        restoredTemplate.overrides
            .singleWhere(
              (override) => override.metadata.id == 'override-chain-2',
            )
            .autoStartNextTimedStepOverride,
        isFalse,
      );
    },
  );

  test('exercise repository round-trips configured steps', () async {
    final now = DateTime.utc(2026, 7, 17, 12);
    final exercise = Exercise(
      metadata: _metadata('exercise-steps-1', now),
      name: 'Burpee complexe',
      hasTimeMeasure: false,
      hasRepsMeasure: true,
      hasScoreMeasure: false,
      defaultTargetReps: 1,
      steps: [
        ExerciseStep(
          id: 'step-1',
          position: 0,
          name: 'Planche',
          type: ExerciseStepType.time,
          defaultTargetValue: 20,
        ),
        ExerciseStep(
          id: 'step-2',
          position: 1,
          name: 'Sauts',
          type: ExerciseStepType.reps,
          defaultTargetValue: 8,
          hasScore: true,
          scoreLabel: 'Amplitude',
          scoreUnit: 'pts',
          defaultTargetScore: 0,
        ),
        ExerciseStep(
          id: 'step-3',
          position: 2,
          name: 'Sprint final',
          type: ExerciseStepType.time,
          defaultTargetValue: 10,
          hasScore: true,
          scoreInputMode: ScoreInputMode.stopwatch,
          defaultTargetScoreTimeMs: 12000,
        ),
      ],
    );

    await exerciseRepository.save(exercise);

    final restored = await exerciseRepository.findById(exercise.metadata.id);

    expect(restored, isNotNull);
    expect(restored!.steps, hasLength(3));
    expect(restored.steps.map((step) => step.name), [
      'Planche',
      'Sauts',
      'Sprint final',
    ]);
    expect(restored.steps[1].defaultTargetScore, 0);
    expect(restored.steps[2].scoreInputMode, ScoreInputMode.stopwatch);
    expect(restored.steps[2].defaultTargetScoreTimeMs, 12000);
  });

  group('exercise repository round-trips exercise option combinations', () {
    final now = DateTime.utc(2026, 7, 17, 12);
    final cases = <_ExerciseRoundTripCase>[
      _ExerciseRoundTripCase(
        label: 'reps without score or steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-reps', now),
          name: 'Pompes',
          description: 'Simple reps target',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
          defaultTargetReps: 12,
        ),
      ),
      _ExerciseRoundTripCase(
        label: 'reps without score and with steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-reps-steps', now),
          name: 'Complexe poids du corps',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
          defaultTargetReps: 1,
          steps: [
            _exerciseStep(
              id: 'combo-reps-step-1',
              position: 0,
              name: 'Pompes',
              type: ExerciseStepType.reps,
              defaultTargetValue: 10,
            ),
            _exerciseStep(
              id: 'combo-reps-step-2',
              position: 1,
              name: 'Gainage',
              type: ExerciseStepType.time,
              defaultTargetValue: 30,
            ),
          ],
        ),
      ),
      _ExerciseRoundTripCase(
        label: 'manual score without steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-manual-score', now),
          name: 'Charge max',
          hasTimeMeasure: false,
          hasRepsMeasure: false,
          hasScoreMeasure: true,
          scoreLabel: 'Charge',
          scoreUnit: 'kg',
          defaultTargetScore: 0,
        ),
      ),
      _ExerciseRoundTripCase(
        label: 'manual score with unscored steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-manual-score-steps', now),
          name: 'Technique haltères',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: true,
          scoreLabel: 'Charge',
          scoreUnit: 'kg',
          defaultTargetReps: 8,
          defaultTargetScore: 12.5,
          steps: [
            _exerciseStep(
              id: 'combo-manual-score-step-1',
              position: 0,
              name: 'Installation',
              type: ExerciseStepType.time,
              defaultTargetValue: 15,
            ),
            _exerciseStep(
              id: 'combo-manual-score-step-2',
              position: 1,
              name: 'Serie',
              type: ExerciseStepType.reps,
              defaultTargetValue: 8,
            ),
          ],
        ),
      ),
      _ExerciseRoundTripCase(
        label: 'manual exercise score with manual scored steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-manual-step-score', now),
          name: 'Circuit precision',
          hasTimeMeasure: true,
          hasRepsMeasure: true,
          hasScoreMeasure: true,
          scoreLabel: 'Qualite',
          scoreUnit: 'pts',
          defaultTargetTimeSeconds: 45,
          defaultTargetReps: 10,
          defaultTargetScore: 80,
          autoStartNextTimedStep: false,
          steps: [
            _exerciseStep(
              id: 'combo-manual-step-score-1',
              position: 0,
              name: 'Bloc reps',
              type: ExerciseStepType.reps,
              defaultTargetValue: 10,
              hasScore: true,
              scoreLabel: 'Amplitude',
              scoreUnit: 'pts',
              defaultTargetScore: 0,
            ),
            _exerciseStep(
              id: 'combo-manual-step-score-2',
              position: 1,
              name: 'Bloc temps',
              type: ExerciseStepType.time,
              defaultTargetValue: 35,
              hasScore: true,
              scoreLabel: 'Tenue',
              scoreUnit: 's',
              defaultTargetScore: 35,
            ),
          ],
        ),
      ),
      _ExerciseRoundTripCase(
        label: 'stopwatch score without steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-stopwatch-score', now),
          name: 'Sprint chrono',
          hasTimeMeasure: true,
          hasRepsMeasure: false,
          hasScoreMeasure: true,
          scoreInputMode: ScoreInputMode.stopwatch,
          defaultTargetTimeSeconds: 20,
          defaultTargetScoreTimeMs: 11500,
        ),
      ),
      _ExerciseRoundTripCase(
        label: 'stopwatch score with stopwatch scored steps',
        exercise: Exercise(
          metadata: _metadata('exercise-combo-stopwatch-score-steps', now),
          name: 'Sprint fractionne',
          imageMediaIds: const ['media-start', 'media-finish'],
          iconMediaId: 'media-start',
          videoMediaId: 'video-demo',
          hasTimeMeasure: true,
          hasRepsMeasure: true,
          hasScoreMeasure: true,
          scoreInputMode: ScoreInputMode.stopwatch,
          defaultTargetTimeSeconds: 60,
          defaultTargetReps: 4,
          defaultTargetScoreTimeMs: 42000,
          steps: [
            _exerciseStep(
              id: 'combo-stopwatch-step-1',
              position: 0,
              name: 'Acceleration',
              type: ExerciseStepType.time,
              defaultTargetValue: 10,
              hasScore: true,
              scoreInputMode: ScoreInputMode.stopwatch,
              defaultTargetScoreTimeMs: 9500,
            ),
            _exerciseStep(
              id: 'combo-stopwatch-step-2',
              position: 1,
              name: 'Recuperation active',
              type: ExerciseStepType.reps,
              defaultTargetValue: 6,
            ),
          ],
        ),
      ),
    ];

    for (final roundTripCase in cases) {
      test(roundTripCase.label, () async {
        await exerciseRepository.save(roundTripCase.exercise);

        final restored = await exerciseRepository.findById(
          roundTripCase.exercise.metadata.id,
        );

        expect(restored, isNotNull);
        _expectExerciseEquals(restored!, roundTripCase.exercise);
      });
    }
  });

  test(
    'program exercise step snapshot is preserved when source exercise changes',
    () async {
      final now = DateTime.utc(2026, 7, 17, 12);
      final source = Exercise(
        metadata: _metadata('exercise-source-1', now),
        name: 'Complexe original',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        defaultTargetReps: 1,
        steps: [
          ExerciseStep(
            id: 'step-original-1',
            position: 0,
            name: 'Phase originale A',
            type: ExerciseStepType.time,
            defaultTargetValue: 30,
          ),
          ExerciseStep(
            id: 'step-original-2',
            position: 1,
            name: 'Phase originale B',
            type: ExerciseStepType.reps,
            defaultTargetValue: 12,
          ),
        ],
      );
      await exerciseRepository.save(source);
      await programRepository.save(
        Program(
          metadata: _metadata('program-steps-1', now),
          name: 'Programme étapes',
          defaultRestSeconds: 60,
        ),
      );
      final useCase = ProgramUseCases(
        programRepository: programRepository,
        exerciseRepository: exerciseRepository,
        templateRepository: templateRepository,
        clock: _FakeClock(now),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final snapshot = await useCase.addExercise(
        programId: 'program-steps-1',
        exerciseId: source.metadata.id,
        position: 0,
        setsCount: 1,
        enabledMeasures: const {WorkoutMeasure.reps},
      );
      await exerciseRepository.save(
        source.copyWith(
          metadata: _metadata(
            source.metadata.id,
            now.add(const Duration(minutes: 1)),
            1,
          ),
          steps: [
            ExerciseStep(
              id: 'step-updated-1',
              position: 0,
              name: 'Phase modifiée',
              type: ExerciseStepType.time,
              defaultTargetValue: 45,
            ),
          ],
        ),
      );

      final restoredProgram = await programRepository.findById(
        'program-steps-1',
      );
      final restoredSnapshot = restoredProgram!.exercises.single;
      final snapshotJson = snapshot.toSnapshotJson();

      expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2));
      expect(
        restoredSnapshot.exerciseStepsSnapshot.first.name,
        'Phase originale A',
      );
      expect(
        restoredSnapshot.exerciseStepsSnapshot.last.type,
        ExerciseStepType.reps,
      );
      expect(
        (snapshotJson['exerciseStepsSnapshot'] as List).map(
          (step) => (step as Map)['name'],
        ),
        ['Phase originale A', 'Phase originale B'],
      );
    },
  );

  test(
    'removing a program exercise writes a soft delete change log entry',
    () async {
      final now = DateTime.utc(2026, 7, 17, 12);
      final first = _programExercise('program-exercise-1', now, position: 0);
      final second = _programExercise('program-exercise-2', now, position: 1);
      await programRepository.save(
        Program(
          metadata: _metadata('program-1', now),
          name: 'Jambes',
          defaultRestSeconds: 60,
          exercises: [first, second],
        ),
      );

      final deletedAt = now.add(const Duration(minutes: 1));
      await programRepository.replaceExercises(
        Program(
          metadata: _metadata('program-1', deletedAt, 1),
          name: 'Jambes',
          defaultRestSeconds: 60,
          exercises: [first],
        ),
        deletedAt,
      );

      final deletedRow = await (database.select(
        database.programExercises,
      )..where((table) => table.id.equals('program-exercise-2'))).getSingle();
      final change =
          await (database.select(database.changeLogEntries)..where(
                (table) =>
                    table.entityType.equals('ProgramExercise') &
                    table.entityId.equals('program-exercise-2') &
                    table.operation.equals('softDelete'),
              ))
              .getSingleOrNull();

      expect(deletedRow.deletedAt?.toUtc(), deletedAt);
      expect(deletedRow.syncState, 'deleted');
      expect(deletedRow.localRevision, 1);
      expect(change, isNotNull);
      expect(change!.localRevision, 1);
    },
  );

  test(
    'running session elapsed time survives repository reconstruction',
    () async {
      final persistedAt = DateTime.utc(2026, 7, 17, 12);
      final session = ActiveWorkoutSession(
        metadata: _metadata('session-1', persistedAt),
        sourceWorkoutTemplateId: null,
        status: ActiveWorkoutStatus.running,
        startedAt: persistedAt.subtract(const Duration(seconds: 30)),
        lastPersistedAt: persistedAt,
        elapsedActiveMs: 30000,
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        resolvedTemplateSnapshotJson: _resolvedSnapshot(),
      );

      await activeRepository.save(session);

      final afterAppKillRepository = local.DriftActiveSessionRepository(
        database,
      );
      final afterAppKillUseCases = ActiveWorkoutSessionUseCases(
        sessionRepository: afterAppKillRepository,
        templateRepository: templateRepository,
        clock: _FakeClock(persistedAt.add(const Duration(seconds: 20))),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final restored = await afterAppKillRepository.findOpen();

      expect(restored, isNotNull);
      expect(afterAppKillUseCases.elapsedActiveMilliseconds(restored!), 50000);
    },
  );

  test(
    'closing a session stores autonomous history rows with set snapshots',
    () async {
      final now = DateTime.utc(2026, 7, 17, 12);
      final template = WorkoutTemplate(
        metadata: _metadata('template-1', now),
        name: 'Séance jambes',
      );
      await templateRepository.save(template);

      final session = ActiveWorkoutSession(
        metadata: _metadata('session-1', now),
        sourceWorkoutTemplateId: template.metadata.id,
        status: ActiveWorkoutStatus.running,
        startedAt: now.subtract(const Duration(minutes: 10)),
        lastPersistedAt: now,
        elapsedActiveMs: 600000,
        currentProgramIndex: 0,
        currentExerciseIndex: 0,
        currentSetIndex: 0,
        resolvedTemplateSnapshotJson: _resolvedSnapshot(),
      );
      await activeRepository.save(session);
      await activeRepository.saveSetResult(
        ActiveSetResult(
          metadata: _metadata('active-result-1', now),
          activeWorkoutSessionId: session.metadata.id,
          programSnapshotId: 'program-snapshot-1',
          exerciseSnapshotId: 'exercise-snapshot-1',
          programIndex: 0,
          exerciseIndex: 0,
          setIndex: 0,
          actualTimeMs: 45000,
          actualReps: 10,
          actualScore: 80,
          scoreLabelSnapshot: 'Charge',
          scoreUnitSnapshot: 'kg',
          completedAt: now,
        ),
      );

      final closeUseCase = CloseWorkoutSessionUseCase(
        sessionRepository: activeRepository,
        historyRepository: historyRepository,
        clock: _FakeClock(now.add(const Duration(seconds: 5))),
        ids: _FakeIds(),
        originDeviceId: 'device-1',
      );

      final history = await closeUseCase.close(
        sessionId: session.metadata.id,
        nameSnapshot: 'Séance jambes',
        completed: true,
      );
      await templateRepository.save(
        WorkoutTemplate(
          metadata: template.metadata.markDeleted(now),
          name: template.name,
        ),
      );

      final restored = await historyRepository.findById(history.metadata.id);

      expect(restored, isNotNull);
      expect(restored!.nameSnapshot, 'Séance jambes');
      expect(restored.results, hasLength(1));
      expect(restored.results.single.programNameSnapshot, 'Programme jambes');
      expect(restored.results.single.exerciseNameSnapshot, 'Squat');
      expect(restored.results.single.actualTimeMs, 45000);
      expect(restored.results.single.actualReps, 10);
      expect(restored.results.single.actualScore, 80);
      expect(restored.results.single.scoreUnitSnapshot, 'kg');
      expect(restored.results.single.sourceExerciseIdSnapshot, 'exercise-1');
    },
  );

  test(
    'performance reference ignores skipped and null values for latest set',
    () async {
      final now = DateTime.utc(2026, 7, 22, 10);
      await historyRepository.save(
        _history(
          id: 'history-old',
          startedAt: now.subtract(const Duration(days: 2)),
          result: _historySetResult(
            id: 'result-old',
            historyId: 'history-old',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now.subtract(const Duration(days: 2)),
            actualReps: 8,
          ),
        ),
      );
      await historyRepository.save(
        _history(
          id: 'history-new',
          startedAt: now,
          results: [
            _historySetResult(
              id: 'result-skipped',
              historyId: 'history-new',
              sourceExerciseId: 'exercise-1',
              setIndex: 0,
              startedAt: now,
              status: SetResultStatus.skipped,
            ),
            _historySetResult(
              id: 'result-null',
              historyId: 'history-new',
              sourceExerciseId: 'exercise-1',
              setIndex: 1,
              startedAt: now,
            ),
            _historySetResult(
              id: 'result-value',
              historyId: 'history-new',
              sourceExerciseId: 'exercise-1',
              setIndex: 2,
              startedAt: now,
              actualReps: 11,
            ),
          ],
        ),
      );

      final latest = await performanceReferenceRepository
          .findLatestSetPerformance(
            exerciseId: 'exercise-1',
            activeMeasures: const ActivePerformanceMeasures(
              timeEnabled: false,
              repsEnabled: true,
              scoreEnabled: false,
            ),
            currentSetIndex: 0,
          );

      expect(latest, isNotNull);
      expect(latest!.workoutHistoryId, 'history-new');
      expect(latest.setIndex, 2);
      expect(latest.actualReps, 11);
    },
  );

  test('performance reference uses same set when available', () async {
    final now = DateTime.utc(2026, 7, 22, 11);
    await historyRepository.save(
      _history(
        id: 'history-sets',
        startedAt: now,
        results: [
          _historySetResult(
            id: 'result-set-0',
            historyId: 'history-sets',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now,
            actualReps: 7,
          ),
          _historySetResult(
            id: 'result-set-1',
            historyId: 'history-sets',
            sourceExerciseId: 'exercise-1',
            setIndex: 1,
            startedAt: now,
            actualReps: 9,
          ),
        ],
      ),
    );

    final latest = await performanceReferenceRepository
        .findLatestSetPerformance(
          exerciseId: 'exercise-1',
          activeMeasures: const ActivePerformanceMeasures(
            timeEnabled: false,
            repsEnabled: true,
            scoreEnabled: false,
          ),
          currentSetIndex: 0,
        );

    expect(latest, isNotNull);
    expect(latest!.setIndex, 0);
    expect(latest.actualReps, 7);
  });

  test('performance reference finds records by metric rules', () async {
    final now = DateTime.utc(2026, 7, 22, 12);
    await historyRepository.save(
      _history(
        id: 'history-records',
        startedAt: now,
        results: [
          _historySetResult(
            id: 'result-reps-low',
            historyId: 'history-records',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now,
            actualTimeMs: 30000,
            actualReps: 6,
            actualScore: 15,
          ),
          _historySetResult(
            id: 'result-reps-high',
            historyId: 'history-records',
            sourceExerciseId: 'exercise-1',
            setIndex: 1,
            startedAt: now,
            actualTimeMs: 45000,
            actualReps: 12,
            actualScore: 20,
          ),
          _historySetResult(
            id: 'result-stopwatch-slow',
            historyId: 'history-records',
            sourceExerciseId: 'exercise-1',
            setIndex: 2,
            startedAt: now,
            scoreInputMode: ScoreInputMode.stopwatch,
            actualScoreTimeMs: 11000,
          ),
          _historySetResult(
            id: 'result-stopwatch-fast',
            historyId: 'history-records',
            sourceExerciseId: 'exercise-1',
            setIndex: 3,
            startedAt: now,
            scoreInputMode: ScoreInputMode.stopwatch,
            actualScoreTimeMs: 9000,
          ),
        ],
      ),
    );

    final reps = await performanceReferenceRepository.findBestMetricPerformance(
      exerciseId: 'exercise-1',
      metric: PerformanceMetric.reps,
      scoreInputMode: ScoreInputMode.manual,
    );
    final time = await performanceReferenceRepository.findBestMetricPerformance(
      exerciseId: 'exercise-1',
      metric: PerformanceMetric.time,
      scoreInputMode: ScoreInputMode.manual,
    );
    final manualScore = await performanceReferenceRepository
        .findBestMetricPerformance(
          exerciseId: 'exercise-1',
          metric: PerformanceMetric.score,
          scoreInputMode: ScoreInputMode.manual,
        );
    final stopwatchScore = await performanceReferenceRepository
        .findBestMetricPerformance(
          exerciseId: 'exercise-1',
          metric: PerformanceMetric.score,
          scoreInputMode: ScoreInputMode.stopwatch,
        );

    expect(reps!.actualReps, 12);
    expect(time!.actualTimeMs, 45000);
    expect(manualScore!.actualScore, 20);
    expect(stopwatchScore!.actualScoreTimeMs, 9000);
  });

  test('performance reference matches archived source exercise id', () async {
    final now = DateTime.utc(2026, 7, 22, 13);
    await exerciseRepository.save(
      Exercise(
        metadata: _metadata('exercise-archived', now),
        name: 'Archived drill',
        hasTimeMeasure: false,
        hasRepsMeasure: true,
        hasScoreMeasure: false,
        archivedAt: now,
      ),
    );
    await historyRepository.save(
      _history(
        id: 'history-archived',
        startedAt: now,
        result: _historySetResult(
          id: 'result-archived',
          historyId: 'history-archived',
          sourceExerciseId: 'exercise-archived',
          setIndex: 0,
          startedAt: now,
          actualReps: 13,
        ),
      ),
    );

    expect(
      await performanceReferenceRepository.hasAnyCompletedHistoryForExercise(
        'exercise-archived',
      ),
      isTrue,
    );
    final latest = await performanceReferenceRepository
        .findLatestSetPerformance(
          exerciseId: 'exercise-archived',
          activeMeasures: const ActivePerformanceMeasures(
            timeEnabled: false,
            repsEnabled: true,
            scoreEnabled: false,
          ),
          currentSetIndex: 0,
        );
    expect(latest!.actualReps, 13);
  });

  test(
    'progression global stats count active weeks and ignore inactive rows',
    () async {
      final now = DateTime.utc(2026, 7, 22, 13);
      await historyRepository.save(
        _history(
          id: 'progression-week-1',
          startedAt: now.subtract(const Duration(days: 1)),
          totalActiveMs: 120000,
          result: _historySetResult(
            id: 'progression-result-1',
            historyId: 'progression-week-1',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now.subtract(const Duration(days: 1)),
            actualReps: 10,
          ),
        ),
      );
      await historyRepository.save(
        _history(
          id: 'progression-week-2',
          startedAt: now.subtract(const Duration(days: 8)),
          totalActiveMs: 180000,
          result: _historySetResult(
            id: 'progression-result-2',
            historyId: 'progression-week-2',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now.subtract(const Duration(days: 8)),
            actualReps: 8,
          ),
        ),
      );
      await historyRepository.save(
        _history(
          id: 'progression-incomplete',
          startedAt: now,
          completed: false,
          result: _historySetResult(
            id: 'progression-result-incomplete',
            historyId: 'progression-incomplete',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now,
            actualReps: 99,
          ),
        ),
      );
      await historyRepository.save(
        _history(
          id: 'progression-deleted',
          startedAt: now.subtract(const Duration(days: 3)),
          totalActiveMs: 900000,
          result: _historySetResult(
            id: 'progression-result-deleted',
            historyId: 'progression-deleted',
            sourceExerciseId: 'exercise-1',
            setIndex: 0,
            startedAt: now.subtract(const Duration(days: 3)),
            actualReps: 20,
          ),
        ),
      );
      await historyRepository.delete('progression-deleted', now);

      final stats = await progressionStatsRepository.readGlobalStats(
        ProgressionDateRange(
          startedAt: now.subtract(const Duration(days: 28)),
          endedAt: now,
        ),
      );

      expect(stats.completedSessionCount, 2);
      expect(stats.totalActiveMs, 300000);
      expect(stats.activeWeekStarts, hasLength(2));
      expect(stats.hasAnyCompletedHistory, isTrue);
    },
  );

  test(
    'progression lists exercises, archive state and measure options',
    () async {
      final now = DateTime.utc(2026, 7, 22, 14);
      await exerciseRepository.save(
        Exercise(
          metadata: _metadata('exercise-active', now),
          name: 'Active drill',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
        ),
      );
      await exerciseRepository.save(
        Exercise(
          metadata: _metadata('exercise-archived', now),
          name: 'Archived drill',
          hasTimeMeasure: false,
          hasRepsMeasure: true,
          hasScoreMeasure: false,
          archivedAt: now,
        ),
      );
      await historyRepository.save(
        _history(
          id: 'progression-options',
          startedAt: now,
          results: [
            _historySetResult(
              id: 'progression-active-score',
              historyId: 'progression-options',
              sourceExerciseId: 'exercise-active',
              setIndex: 0,
              startedAt: now,
              actualScore: 12,
            ),
            _historySetResult(
              id: 'progression-active-chrono',
              historyId: 'progression-options',
              sourceExerciseId: 'exercise-active',
              setIndex: 1,
              startedAt: now,
              scoreInputMode: ScoreInputMode.stopwatch,
              actualScoreTimeMs: 42000,
            ),
            _historySetResult(
              id: 'progression-archived-reps',
              historyId: 'progression-options',
              sourceExerciseId: 'exercise-archived',
              setIndex: 2,
              startedAt: now,
              actualReps: 15,
            ),
            _historySetResult(
              id: 'progression-skipped',
              historyId: 'progression-options',
              sourceExerciseId: 'exercise-skipped',
              setIndex: 3,
              startedAt: now,
              status: SetResultStatus.skipped,
            ),
            _historySetResult(
              id: 'progression-absent-value',
              historyId: 'progression-options',
              sourceExerciseId: 'exercise-absent',
              setIndex: 4,
              startedAt: now,
            ),
          ],
        ),
      );

      final range = ProgressionDateRange(
        startedAt: now.subtract(const Duration(days: 1)),
        endedAt: now,
      );
      final options = await progressionStatsRepository.listExerciseOptions(
        range,
      );
      final activeMeasures = await progressionStatsRepository
          .listMeasureOptions(range: range, exerciseKey: 'exercise-active');

      expect(options.map((option) => option.exerciseKey), [
        'exercise-active',
        'exercise-archived',
      ]);
      expect(options.first.isArchived, isFalse);
      expect(options.last.isArchived, isTrue);
      expect(activeMeasures.map((option) => option.measure), [
        ProgressionMeasure.manualScore,
        ProgressionMeasure.stopwatchScore,
      ]);
      expect(activeMeasures.first.scoreLabel, 'Score');
      expect(activeMeasures.first.scoreUnit, 'pts');
      expect(activeMeasures.last.lowerIsBetter, isTrue);
    },
  );

  test(
    'progression series reports completed exercise rows without graphable value',
    () async {
      final now = DateTime.utc(2026, 7, 22, 14, 30);
      await historyRepository.save(
        _history(
          id: 'progression-no-graphable',
          startedAt: now,
          result: _historySetResult(
            id: 'progression-no-graphable-result',
            historyId: 'progression-no-graphable',
            sourceExerciseId: 'exercise-no-graphable',
            setIndex: 0,
            startedAt: now,
          ),
        ),
      );

      final series = await progressionStatsRepository.readExerciseSeries(
        range: ProgressionDateRange(
          startedAt: now.subtract(const Duration(days: 1)),
          endedAt: now,
        ),
        exerciseKey: 'exercise-no-graphable',
        measure: ProgressionMeasure.reps,
      );

      expect(series.points, isEmpty);
      expect(series.hasAnyAllTimeData, isFalse);
      expect(series.hasAnyCompletedExerciseResult, isTrue);
    },
  );

  test(
    'progression exercise series aggregate each measure by session',
    () async {
      final now = DateTime.utc(2026, 7, 22, 15);
      await historyRepository.save(
        _history(
          id: 'progression-series-old',
          startedAt: now.subtract(const Duration(days: 2)),
          results: [
            _historySetResult(
              id: 'progression-old-score-low',
              historyId: 'progression-series-old',
              sourceExerciseId: 'exercise-1',
              setIndex: 0,
              startedAt: now.subtract(const Duration(days: 2)),
              actualScore: 7,
            ),
            _historySetResult(
              id: 'progression-old-score-high',
              historyId: 'progression-series-old',
              sourceExerciseId: 'exercise-1',
              setIndex: 1,
              startedAt: now.subtract(const Duration(days: 2)),
              actualScore: 9,
              actualReps: 4,
              actualTimeMs: 10000,
            ),
          ],
        ),
      );
      await historyRepository.save(
        _history(
          id: 'progression-series-new',
          startedAt: now,
          results: [
            _historySetResult(
              id: 'progression-new-score',
              historyId: 'progression-series-new',
              sourceExerciseId: 'exercise-1',
              setIndex: 0,
              startedAt: now,
              actualScore: 11,
              actualReps: 6,
              actualTimeMs: 12000,
            ),
            _historySetResult(
              id: 'progression-new-chrono-slow',
              historyId: 'progression-series-new',
              sourceExerciseId: 'exercise-1',
              setIndex: 1,
              startedAt: now,
              scoreInputMode: ScoreInputMode.stopwatch,
              actualScoreTimeMs: 45000,
            ),
            _historySetResult(
              id: 'progression-new-chrono-fast',
              historyId: 'progression-series-new',
              sourceExerciseId: 'exercise-1',
              setIndex: 2,
              startedAt: now,
              scoreInputMode: ScoreInputMode.stopwatch,
              actualScoreTimeMs: 42000,
            ),
          ],
        ),
      );
      final range = ProgressionDateRange(
        startedAt: now.subtract(const Duration(days: 7)),
        endedAt: now,
      );

      final score = await progressionStatsRepository.readExerciseSeries(
        range: range,
        exerciseKey: 'exercise-1',
        measure: ProgressionMeasure.manualScore,
      );
      final chrono = await progressionStatsRepository.readExerciseSeries(
        range: range,
        exerciseKey: 'exercise-1',
        measure: ProgressionMeasure.stopwatchScore,
      );
      final reps = await progressionStatsRepository.readExerciseSeries(
        range: range,
        exerciseKey: 'exercise-1',
        measure: ProgressionMeasure.reps,
      );
      final time = await progressionStatsRepository.readExerciseSeries(
        range: range,
        exerciseKey: 'exercise-1',
        measure: ProgressionMeasure.time,
      );

      expect(score.points.map((point) => point.rawValue), [9.0, 11.0]);
      expect(chrono.points.single.rawValue, 42000);
      expect(reps.points.map((point) => point.rawValue), [4, 6]);
      expect(time.points.map((point) => point.rawValue), [10000, 12000]);
      expect(score.points.first.workoutHistoryId, 'progression-series-old');
    },
  );

  test('progression reports all-time data outside selected period', () async {
    final now = DateTime.utc(2026, 7, 22, 16);
    await historyRepository.save(
      _history(
        id: 'progression-all-time',
        startedAt: now.subtract(const Duration(days: 60)),
        result: _historySetResult(
          id: 'progression-all-time-score',
          historyId: 'progression-all-time',
          sourceExerciseId: 'exercise-1',
          setIndex: 0,
          startedAt: now.subtract(const Duration(days: 60)),
          actualScore: 10,
        ),
      ),
    );

    final series = await progressionStatsRepository.readExerciseSeries(
      range: ProgressionDateRange(
        startedAt: now.subtract(const Duration(days: 7)),
        endedAt: now,
      ),
      exerciseKey: 'exercise-1',
      measure: ProgressionMeasure.manualScore,
    );

    expect(series.points, isEmpty);
    expect(series.hasAnyAllTimeData, isTrue);
  });

  test(
    'performance reference use case prioritizes record score metric',
    () async {
      final repository = _FakePerformanceReferenceRepository();
      final useCase = ExercisePerformanceReferenceUseCase(
        repository: repository,
      );

      final reference = await useCase.getExercisePerformanceReference(
        exerciseId: 'exercise-1',
        activeMeasures: const ActivePerformanceMeasures(
          timeEnabled: true,
          repsEnabled: true,
          scoreEnabled: true,
        ),
        currentSetIndex: 0,
      );

      expect(reference.hasAnyHistoryForExercise, isTrue);
      expect(repository.requestedMetric, PerformanceMetric.score);
    },
  );
}

String _resolvedSnapshot() {
  return jsonEncode({
    'name': 'Séance jambes',
    'programs': [
      {
        'id': 'program-snapshot-1',
        'programNameSnapshot': 'Programme jambes',
        'programSnapshotJson': jsonEncode({
          'exercises': [
            {
              'id': 'exercise-snapshot-1',
              'sourceExerciseId': 'exercise-1',
              'exerciseNameSnapshot': 'Squat',
              'setsCount': 1,
              'timeEnabled': true,
              'repsEnabled': true,
              'scoreEnabled': true,
              'targetTimeSeconds': 45,
              'targetReps': 10,
              'targetScore': 80,
              'scoreLabelSnapshot': 'Charge',
              'scoreUnitSnapshot': 'kg',
              'restSecondsOverride': 0,
            },
          ],
        }),
      },
    ],
    'overrides': const [],
  });
}

EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
  return EntityMetadata(
    id: id,
    createdAt: now,
    updatedAt: now,
    originDeviceId: 'device-1',
    localRevision: localRevision,
  );
}

LocalBackupResource _backupExerciseResource({
  required String id,
  required String name,
  required DateTime updatedAt,
}) {
  return LocalBackupResource(
    id: id,
    updatedAt: updatedAt,
    payload: {
      'id': id,
      'metadata': {
        'id': id,
        'createdAt': updatedAt.toUtc().toIso8601String(),
        'updatedAt': updatedAt.toUtc().toIso8601String(),
        'deletedAt': null,
        'schemaVersion': 1,
        'syncState': 'synced',
        'localRevision': 0,
        'originDeviceId': 'device-backup',
      },
      'name': name,
      'hasTimeMeasure': false,
      'hasRepsMeasure': true,
      'hasScoreMeasure': false,
      'defaultTargetReps': 10,
      'tags': const ['backup'],
      'steps': const [],
    },
  );
}

WorkoutHistory _history({
  required String id,
  required DateTime startedAt,
  WorkoutHistorySetResult? result,
  List<WorkoutHistorySetResult>? results,
  bool completed = true,
  int totalActiveMs = 300000,
}) {
  return WorkoutHistory(
    metadata: _metadata(id, startedAt),
    nameSnapshot: id,
    startedAt: startedAt,
    endedAt: startedAt.add(const Duration(minutes: 5)),
    totalActiveMs: totalActiveMs,
    completed: completed,
    historySnapshotJson: '{"name":"$id"}',
    results: results ?? [result!],
  );
}

WorkoutHistoryStepResult _historyStepResult({
  required String id,
  required String historyId,
  required String sourceExerciseId,
  required DateTime startedAt,
}) {
  return WorkoutHistoryStepResult(
    metadata: _metadata(id, startedAt),
    workoutHistoryId: historyId,
    programSnapshotId: 'program-snapshot',
    exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
    programIndex: 0,
    exerciseIndex: 0,
    setIndex: 0,
    passageIndex: 0,
    stepIndex: 0,
    stepSnapshotId: 'step-snapshot',
    stepNameSnapshot: 'Step',
    stepTypeSnapshot: ExerciseStepType.reps,
    targetValueSnapshot: 10,
    hasScoreSnapshot: false,
    status: SetResultStatus.completed,
    startedAt: startedAt,
    completedAt: startedAt.add(const Duration(seconds: 10)),
    actualReps: 10,
    sourceExerciseIdSnapshot: sourceExerciseId,
  );
}

WorkoutHistorySetResult _historySetResult({
  required String id,
  required String historyId,
  required String sourceExerciseId,
  required int setIndex,
  required DateTime startedAt,
  int? actualTimeMs,
  int? actualReps,
  double? actualScore,
  int? actualScoreTimeMs,
  ScoreInputMode scoreInputMode = ScoreInputMode.manual,
  SetResultStatus status = SetResultStatus.completed,
}) {
  final scoreEnabled = actualScore != null || actualScoreTimeMs != null;
  return WorkoutHistorySetResult(
    metadata: _metadata(id, startedAt),
    workoutHistoryId: historyId,
    programSnapshotId: 'program-snapshot',
    exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
    programIndex: 0,
    exerciseIndex: 0,
    setIndex: setIndex,
    programNameSnapshot: 'Program',
    exerciseNameSnapshot: 'Exercise',
    timeEnabledSnapshot: actualTimeMs != null,
    repsEnabledSnapshot:
        actualReps != null || (actualTimeMs == null && !scoreEnabled),
    scoreEnabledSnapshot: scoreEnabled,
    actualTimeMs: actualTimeMs,
    actualReps: actualReps,
    actualScore: actualScore,
    actualScoreTimeMs: actualScoreTimeMs,
    scoreInputModeSnapshot: scoreInputMode,
    scoreLabelSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
        ? 'Score'
        : null,
    scoreUnitSnapshot: scoreEnabled && scoreInputMode == ScoreInputMode.manual
        ? 'pts'
        : null,
    sourceExerciseIdSnapshot: sourceExerciseId,
    completedAt: status == SetResultStatus.completed
        ? startedAt.add(const Duration(minutes: 1))
        : null,
    status: status,
  );
}

ProgramExercise _programExercise(
  String id,
  DateTime now, {
  String programId = 'program-1',
  required int position,
  bool autoStartNextTimedStepSnapshot = true,
  bool? autoStartNextTimedStepOverride,
}) {
  return ProgramExercise(
    metadata: _metadata(id, now),
    programId: programId,
    position: position,
    exerciseNameSnapshot: 'Exercise $position',
    autoStartNextTimedStepSnapshot: autoStartNextTimedStepSnapshot,
    autoStartNextTimedStepOverride: autoStartNextTimedStepOverride,
    availableTimeSnapshot: false,
    availableRepsSnapshot: true,
    availableScoreSnapshot: false,
    setsCount: 1,
    timeEnabled: false,
    repsEnabled: true,
    scoreEnabled: false,
  );
}

ExerciseStep _exerciseStep({
  required String id,
  required int position,
  required String name,
  required ExerciseStepType type,
  required int defaultTargetValue,
  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,
  );
}

void _expectExerciseEquals(Exercise actual, Exercise expected) {
  _expectMetadataEquals(actual.metadata, expected.metadata);
  expect(actual.name, expected.name);
  expect(actual.description, expected.description);
  expect(actual.imageMediaIds, expected.imageMediaIds);
  expect(actual.iconMediaId, expected.iconMediaId);
  expect(actual.videoMediaId, expected.videoMediaId);
  expect(actual.hasTimeMeasure, expected.hasTimeMeasure);
  expect(actual.hasRepsMeasure, expected.hasRepsMeasure);
  expect(actual.hasScoreMeasure, expected.hasScoreMeasure);
  expect(actual.scoreInputMode, expected.scoreInputMode);
  expect(actual.scoreLabel, expected.scoreLabel);
  expect(actual.scoreUnit, expected.scoreUnit);
  expect(actual.defaultTargetTimeSeconds, expected.defaultTargetTimeSeconds);
  expect(actual.defaultTargetReps, expected.defaultTargetReps);
  expect(actual.defaultTargetScore, expected.defaultTargetScore);
  expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs);
  expect(actual.autoStartNextTimedStep, expected.autoStartNextTimedStep);
  expect(actual.archivedAt?.toUtc(), expected.archivedAt?.toUtc());
  expect(actual.steps, hasLength(expected.steps.length));
  for (var index = 0; index < expected.steps.length; index++) {
    _expectExerciseStepEquals(actual.steps[index], expected.steps[index]);
  }
}

void _expectExerciseStepEquals(ExerciseStep actual, ExerciseStep expected) {
  expect(actual.id, expected.id);
  expect(actual.position, expected.position);
  expect(actual.name, expected.name);
  expect(actual.type, expected.type);
  expect(actual.defaultTargetValue, expected.defaultTargetValue);
  expect(actual.hasScore, expected.hasScore);
  expect(actual.scoreInputMode, expected.scoreInputMode);
  expect(actual.scoreLabel, expected.scoreLabel);
  expect(actual.scoreUnit, expected.scoreUnit);
  expect(actual.defaultTargetScore, expected.defaultTargetScore);
  expect(actual.defaultTargetScoreTimeMs, expected.defaultTargetScoreTimeMs);
}

void _expectMetadataEquals(EntityMetadata actual, EntityMetadata expected) {
  expect(actual.id, expected.id);
  expect(actual.createdAt.toUtc(), expected.createdAt.toUtc());
  expect(actual.updatedAt.toUtc(), expected.updatedAt.toUtc());
  expect(actual.deletedAt?.toUtc(), expected.deletedAt?.toUtc());
  expect(actual.schemaVersion, expected.schemaVersion);
  expect(actual.syncState, expected.syncState);
  expect(actual.localRevision, expected.localRevision);
  expect(actual.originDeviceId, expected.originDeviceId);
  expect(actual.futureOwnerProfileId, expected.futureOwnerProfileId);
  expect(actual.lastSyncedAt?.toUtc(), expected.lastSyncedAt?.toUtc());
  expect(actual.remoteRevision, expected.remoteRevision);
}

final class _ExerciseRoundTripCase {
  const _ExerciseRoundTripCase({required this.label, required this.exercise});

  final String label;
  final Exercise exercise;
}

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

  final DateTime value;

  @override
  DateTime now() => value;
}

final class _FakeIds implements IdGenerator {
  var _next = 0;

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

final class _FakePerformanceReferenceRepository
    implements ExercisePerformanceReferenceRepository {
  PerformanceMetric? requestedMetric;

  @override
  Future<bool> hasAnyCompletedHistoryForExercise(String exerciseId) async {
    return true;
  }

  @override
  Future<WorkoutHistorySetPerformance?> findLatestSetPerformance({
    required String exerciseId,
    required ActivePerformanceMeasures activeMeasures,
    required int currentSetIndex,
  }) async {
    return null;
  }

  @override
  Future<WorkoutHistoryMetricPerformance?> findBestMetricPerformance({
    required String exerciseId,
    required PerformanceMetric metric,
    required ScoreInputMode scoreInputMode,
  }) async {
    requestedMetric = metric;
    return WorkoutHistoryMetricPerformance(
      workoutHistoryId: 'history-1',
      startedAt: DateTime.utc(2026, 7, 22),
      setIndex: 0,
      exerciseNameSnapshot: 'Exercise',
      metric: metric,
      scoreInputModeSnapshot: scoreInputMode,
      actualScore: 10,
    );
  }
}
