import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime_watch/application/watch_session_view_model.dart';
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
import 'package:gametime_watch/presentation/watch_session_screen.dart';
import 'package:gametime_watch/presentation/watch_theme.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  testWidgets(
    'renders an active session on a compact round-sized viewport without overflow',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_runningProjection());
      await tester.pump();
      await tester.pump(const Duration(milliseconds: 100));

      expect(find.text('Squat jump'), findsOneWidget);
      expect(find.text('02:14'), findsOneWidget);
      expect(find.text('Chrono étape'), findsOneWidget);
      expect(find.byTooltip('Pause'), findsOneWidget);
      expect(find.text('Séance active'), findsNothing);
      expect(find.text('Série 00:45'), findsNothing);
      expect(tester.takeException(), isNull);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'transitions from no-session to manual score controls without rendering a black screen',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_noSessionStartProjection());
      await tester.pump();

      expect(find.text('Aucune séance en cours'), findsOneWidget);

      client.emitProjection(_manualScoreProjection());
      await tester.pump();
      await tester.pump(const Duration(milliseconds: 100));

      expect(find.text('Lancers francs'), findsOneWidget);
      expect(find.text('Routine de tir'), findsOneWidget);
      expect(find.text('Cible : 8'), findsOneWidget);
      expect(find.text('3'), findsOneWidget);
      expect(find.byTooltip('Ajouter'), findsOneWidget);
      expect(find.byTooltip('Retirer'), findsOneWidget);
      expect(tester.takeException(), isNull);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets('shows reps target on step manual score content', (tester) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_manualScoreProjectionWithRepsTarget());
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 100));

    expect(find.text('Répétitions : 10 · Cible : 8'), findsOneWidget);
    expect(find.text('SCORE'), findsOneWidget);
    expect(find.byTooltip('Ajouter'), findsOneWidget);
    expect(find.byTooltip('Valider l’étape'), findsNothing);
    expect(tester.takeException(), isNull);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('hides set timer even when it is projected as dominant', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_setTimerDominantProjection());
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 100));

    expect(find.text('Chrono étape'), findsOneWidget);
    expect(find.text('02:14'), findsOneWidget);
    expect(find.text('Série'), findsNothing);
    expect(find.text('00:45'), findsNothing);
    expect(find.byTooltip('Pause'), findsOneWidget);
    expect(tester.takeException(), isNull);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('does not offer a start action from the no-session screen', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_noSessionStartProjection());
    await tester.pump();

    expect(find.text('Aucune séance en cours'), findsOneWidget);
    expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
    expect(client.sentCommands, isEmpty);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('starts a stopped exercise timer from the timer button', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_readyProjection());
    await tester.pump();

    expect(find.text('Routine de tir'), findsOneWidget);

    await tester.tap(find.byTooltip('Démarrer'));
    await tester.pump();

    expect(
      client.sentCommands.single.type,
      WatchCommandType.startCurrentExercise,
    );
    expect(
      find.byKey(const ValueKey('timer-toggle-pending-dot')),
      findsOneWidget,
    );

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('keeps the no-session start action disabled without phone', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
    client.emitProjection(_noSessionStartProjection(phoneReachable: false));
    await tester.pump();

    expect(find.text('Téléphone indisponible'), findsOneWidget);
    expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('shows compact timer controls with a manual score step', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_manualScoreProjectionWithTimer());
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 100));

    expect(find.text('3'), findsOneWidget);
    expect(find.text('Routine de tir'), findsOneWidget);
    expect(find.text('Chrono étape'), findsOneWidget);
    expect(find.text('02:14'), findsOneWidget);
    expect(find.byTooltip('Pause'), findsOneWidget);
    expect(tester.takeException(), isNull);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets(
    'shows live heart rate on main page and full telemetry on stats',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_runningProjection());
      await tester.pump();
      expect(find.byTooltip('Stats'), findsNothing);

      client.emitSensorSample(
        WatchSensorSample(
          sessionId: 'session-1',
          capturedAtEpochMs: DateTime.utc(
            2026,
            7,
            27,
            10,
          ).millisecondsSinceEpoch,
          heartRateBpm: 142,
          distanceMeters: 840,
          caloriesKcal: 186,
        ),
      );
      await tester.pump();

      expect(find.text('FC 142 bpm'), findsNothing);
      expect(find.byIcon(Icons.favorite), findsOneWidget);
      expect(find.text('142 bpm'), findsOneWidget);
      expect(find.text('840 m'), findsNothing);
      expect(find.text('186 kcal'), findsNothing);
      expect(find.byTooltip('Stats'), findsOneWidget);

      await tester.drag(
        find.byKey(const ValueKey('watch-session-page')),
        const Offset(-220, 0),
      );
      await tester.pumpAndSettle();
      await tester.drag(
        find.byKey(const ValueKey('watch-actions-page')),
        const Offset(-220, 0),
      );
      await tester.pumpAndSettle();

      expect(find.text('Stats'), findsOneWidget);
      expect(find.text('FC'), findsOneWidget);
      expect(find.text('142 bpm'), findsOneWidget);
      expect(find.text('840 m'), findsOneWidget);
      expect(find.text('186 kcal'), findsOneWidget);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'shows explicit metric states when sensor permission is required',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_runningProjection());
      await tester.pump();
      client.emitSensorSample(
        WatchSensorSample(
          sessionId: 'session-1',
          capturedAtEpochMs: DateTime.utc(
            2026,
            7,
            30,
            10,
          ).millisecondsSinceEpoch,
          sensorCollectionStatus: 'authorizationRequired',
        ),
      );
      await tester.pump();

      expect(find.byTooltip('Stats'), findsOneWidget);

      await tester.drag(
        find.byKey(const ValueKey('watch-session-page')),
        const Offset(-220, 0),
      );
      await tester.pumpAndSettle();
      await tester.drag(
        find.byKey(const ValueKey('watch-actions-page')),
        const Offset(-220, 0),
      );
      await tester.pumpAndSettle();

      expect(find.text('Stats'), findsOneWidget);
      expect(find.text('Autorisation requise'), findsNWidgets(3));
      expect(tester.takeException(), isNull);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'sends pause command from the icon button when a timer dominates',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_runningProjection());
      await tester.pump();

      await tester.tap(find.byTooltip('Pause'));
      await tester.pump();

      expect(client.sentCommands.single.type, WatchCommandType.pauseSession);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets('shows pending feedback inside the pause button', (tester) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_runningProjection());
    await tester.pump();

    await tester.tap(find.byTooltip('Pause'));
    await tester.pump();

    expect(
      find.byKey(const ValueKey('timer-toggle-pending-dot')),
      findsOneWidget,
    );
    expect(client.sentCommands.single.type, WatchCommandType.pauseSession);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets(
    'returns to session and shows a discreet notice when a set command is rejected',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_runningProjection());
      await tester.pump();

      await tester.tap(find.byTooltip('Actions'));
      await tester.pumpAndSettle();
      await tester.tap(find.text('Terminer la série'));
      await tester.pump();
      await tester.pump(const Duration(milliseconds: 200));

      expect(
        client.sentCommands.single.type,
        WatchCommandType.finishCurrentSet,
      );
      expect(find.text('Squat jump'), findsOneWidget);

      client.emitAck(
        WatchCommandAckEvent(
          commandId: client.sentCommands.single.commandId,
          status: WatchCommandAck.rejectedNotApplicable,
          sessionId: 'session-1',
        ),
      );
      await tester.pump();
      await tester.pump();

      expect(find.text('Série non modifiée'), findsOneWidget);

      await tester.pump(const Duration(milliseconds: 1900));
      expect(find.text('Série non modifiée'), findsNothing);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'keeps rest skip secondary action open until the session projection updates',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_secondaryRestActionProjection());
      await tester.pump();

      await tester.tap(find.byTooltip('Actions'));
      await tester.pumpAndSettle();
      _expectActionsPageVisible(tester);

      await tester.tap(find.text('Passer le repos'));
      await tester.pump();

      expect(client.sentCommands.single.type, WatchCommandType.skipCurrentRest);
      _expectActionsPageVisible(tester);

      client.emitProjection(_runningProjection());
      await tester.pump();
      await tester.pumpAndSettle();

      _expectSessionPageVisible(tester);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'keeps rest skip secondary action open and shows feedback when rejected',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_secondaryRestActionProjection());
      await tester.pump();

      await tester.tap(find.byTooltip('Actions'));
      await tester.pumpAndSettle();
      await tester.tap(find.text('Passer le repos'));
      await tester.pump();

      client.emitAck(
        WatchCommandAckEvent(
          commandId: client.sentCommands.single.commandId,
          status: WatchCommandAck.rejectedNotApplicable,
          sessionId: 'session-1',
        ),
      );
      await tester.pump();
      await tester.pump();

      _expectActionsPageVisible(tester);
      expect(find.text('Commande non appliquée'), findsOneWidget);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'renders rest without overlapping series progress and next name',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_restProjection());
      await tester.pump();
      await tester.pump(const Duration(milliseconds: 100));

      expect(find.text('REPOS'), findsOneWidget);
      expect(find.text('Après série 2/4'), findsOneWidget);
      expect(find.text('Ensuite'), findsOneWidget);
      expect(find.text('Gainage latéral'), findsOneWidget);
      expect(find.text('Série 2/4'), findsNothing);
      expect(find.textContaining('Ensuite :'), findsNothing);
      expect(find.text('00:30'), findsOneWidget);
      expect(find.byTooltip('Pause'), findsOneWidget);
      expect(tester.takeException(), isNull);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets('hides timer controls when no active timer is projected', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_manualScoreProjection());
    await tester.pump();

    expect(find.text('Chrono étape'), findsNothing);
    expect(find.text('02:14'), findsNothing);
    expect(find.byTooltip('Pause'), findsNothing);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('shows reps target and direct completion on a reps-only step', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_repsStepProjection());
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 100));

    expect(find.text('Répétitions'), findsOneWidget);
    expect(find.text('12'), findsOneWidget);
    expect(find.text('SÉRIE'), findsNothing);
    expect(find.text('Prêt pour la série suivante'), findsNothing);
    expect(find.byTooltip('Valider l’étape'), findsOneWidget);
    expect(tester.takeException(), isNull);

    await tester.tap(find.byTooltip('Valider l’étape'));
    await tester.pump();

    expect(
      client.sentCommands.single.type,
      WatchCommandType.completeCurrentStep,
    );

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets(
    'keeps optimistic score visible after timeout until explicit rejection',
    (tester) async {
      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(
        nativeClient: client,
        waitingThreshold: const Duration(milliseconds: 50),
        commandTimeout: const Duration(milliseconds: 120),
      );

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_manualScoreProjection());
      await tester.pump();

      await tester.tap(find.byTooltip('Ajouter'));
      await tester.pump();

      final commandId = client.sentCommands.single.commandId;
      expect(find.text('4'), findsOneWidget);

      await tester.pump(const Duration(milliseconds: 150));

      expect(find.text('4'), findsOneWidget);
      expect(find.byKey(const ValueKey('score-pending-dot')), findsOneWidget);
      expect(client.resyncRequests, greaterThanOrEqualTo(2));

      client.emitProjection(_manualScoreProjection());
      await tester.pump();
      expect(find.text('4'), findsOneWidget);

      client.emitAck(
        WatchCommandAckEvent(
          commandId: commandId,
          status: WatchCommandAck.rejectedNotApplicable,
          sessionId: 'session-1',
        ),
      );
      await tester.pump();

      expect(find.text('3'), findsOneWidget);

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets('blocks score commands until reconnect projection resyncs', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_manualScoreProjection());
    await tester.pump();

    client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
    await tester.pump();
    client.emitConnection(
      const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
    );
    await tester.pump();

    await viewModel.incrementScore();
    await tester.pump();

    expect(client.sentCommands, isEmpty);
    expect(client.resyncRequests, greaterThanOrEqualTo(2));

    client.emitProjection(_manualScoreProjection());
    await tester.pump();

    await viewModel.incrementScore();
    await tester.pump();

    expect(client.sentCommands.single.type, WatchCommandType.incrementScore);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('blocks session actions until reconnect projection resyncs', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_runningProjection());
    await tester.pump();

    client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
    await tester.pump();
    client.emitConnection(
      const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
    );
    await tester.pump();

    await viewModel.sendPrimaryAction();
    await tester.pump();

    expect(client.sentCommands, isEmpty);
    expect(client.resyncRequests, greaterThanOrEqualTo(2));

    client.emitProjection(_runningProjection());
    await tester.pump();

    await viewModel.sendPrimaryAction();
    await tester.pump();

    expect(client.sentCommands.single.type, WatchCommandType.pauseSession);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('blocks score commands while projection is stale', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(
      nativeClient: client,
      staleProjectionThreshold: const Duration(milliseconds: 50),
      connectionLostThreshold: const Duration(milliseconds: 150),
    );

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(
      _manualScoreProjection(expiresIn: const Duration(seconds: 3)),
    );
    await tester.pump(const Duration(milliseconds: 60));

    await viewModel.incrementScore();
    await tester.pump();

    expect(viewModel.value.staleProjection, isTrue);
    expect(client.sentCommands, isEmpty);

    client.emitProjection(_manualScoreProjection());
    await tester.pump();

    await viewModel.incrementScore();
    await tester.pump();

    expect(client.sentCommands.single.type, WatchCommandType.incrementScore);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets(
    'uses a strong pulse sequence when a countdown timer reaches zero',
    (tester) async {
      final hapticCalls = <MethodCall>[];
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(SystemChannels.platform, (call) async {
            if (call.method == 'HapticFeedback.vibrate') {
              hapticCalls.add(call);
            }
            return null;
          });
      addTearDown(() {
        TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
            .setMockMethodCallHandler(SystemChannels.platform, null);
      });

      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(viewModel: viewModel),
        ),
      );

      client.emitProjection(_countdownProjection(accumulatedMs: 29000));
      await tester.pump();
      expect(hapticCalls, isEmpty);

      client.emitProjection(_countdownProjection(accumulatedMs: 30000));
      await tester.pump();
      await tester.pump(const Duration(milliseconds: 400));

      expect(hapticCalls, hasLength(3));
      expect(
        hapticCalls.map((call) => call.arguments),
        everyElement('HapticFeedbackType.heavyImpact'),
      );

      client.emitProjection(_countdownProjection(accumulatedMs: 30000));
      await tester.pump();
      expect(hapticCalls, hasLength(3));

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets(
    'keeps ticking a visible running countdown until completion without a new projection',
    (tester) async {
      final hapticCalls = <MethodCall>[];
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(SystemChannels.platform, (call) async {
            if (call.method == 'HapticFeedback.vibrate') {
              hapticCalls.add(call);
            }
            return null;
          });
      addTearDown(() {
        TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
            .setMockMethodCallHandler(SystemChannels.platform, null);
      });

      final client = _FakeNativeWatchBridgeClient();
      final viewModel = WatchSessionViewModel(nativeClient: client);
      var nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;

      tester.view.devicePixelRatio = 1;
      tester.view.physicalSize = const Size(192, 192);
      addTearDown(tester.view.resetPhysicalSize);
      addTearDown(tester.view.resetDevicePixelRatio);

      await tester.pumpWidget(
        MaterialApp(
          theme: watchTheme(),
          home: WatchSessionScreen(
            viewModel: viewModel,
            nowEpochMs: () => nowMs,
          ),
        ),
      );

      client.emitProjection(
        _liveCountdownProjection(remainingMs: 1200, nowMs: nowMs),
      );
      await tester.pump();
      expect(find.text('00:01'), findsOneWidget);
      expect(hapticCalls, isEmpty);

      nowMs += 1300;
      await tester.pump(const Duration(milliseconds: 1300));
      await tester.pump(const Duration(milliseconds: 400));

      expect(find.text('00:00'), findsOneWidget);
      expect(hapticCalls, hasLength(3));

      await tester.pumpWidget(const SizedBox.shrink());
      viewModel.dispose();
    },
  );

  testWidgets('does not tick a visible paused countdown', (tester) async {
    final hapticCalls = <MethodCall>[];
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(SystemChannels.platform, (call) async {
          if (call.method == 'HapticFeedback.vibrate') {
            hapticCalls.add(call);
          }
          return null;
        });
    addTearDown(() {
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(SystemChannels.platform, null);
    });

    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(_pausedCountdownProjection(remainingMs: 1200));
    await tester.pump();
    expect(find.text('00:01'), findsOneWidget);

    await tester.pump(const Duration(milliseconds: 2300));
    await tester.pump(const Duration(milliseconds: 400));

    expect(find.text('00:01'), findsOneWidget);
    expect(hapticCalls, isEmpty);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('expires an orphaned active projection after its TTL', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(nativeClient: client);

    tester.view.devicePixelRatio = 1;
    tester.view.physicalSize = const Size(192, 192);
    addTearDown(tester.view.resetPhysicalSize);
    addTearDown(tester.view.resetDevicePixelRatio);

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(
      _expiringProjection(expiresIn: const Duration(seconds: 1)),
    );
    await tester.pump();
    expect(find.text('Squat jump'), findsOneWidget);

    await tester.pump(const Duration(seconds: 2));

    expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession);
    expect(viewModel.value.connectionLost, isTrue);
    expect(client.invalidatedProjectionCount, 1);
    expect(find.text('Téléphone indisponible'), findsOneWidget);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('marks projection freshness only when thresholds are reached', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(
      nativeClient: client,
      staleProjectionThreshold: const Duration(milliseconds: 100),
      connectionLostThreshold: const Duration(milliseconds: 220),
    );

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(
      _expiringProjection(expiresIn: const Duration(seconds: 3)),
    );
    await tester.pump();

    expect(viewModel.value.staleProjection, isFalse);
    expect(viewModel.value.connectionLost, isFalse);

    await tester.pump(const Duration(milliseconds: 90));
    expect(viewModel.value.staleProjection, isFalse);
    expect(viewModel.value.connectionLost, isFalse);

    await tester.pump(const Duration(milliseconds: 20));
    expect(viewModel.value.staleProjection, isTrue);
    expect(viewModel.value.connectionLost, isFalse);
    expect(find.text('Dernier état reçu'), findsOneWidget);

    await tester.pump(const Duration(milliseconds: 130));
    expect(viewModel.value.staleProjection, isTrue);
    expect(viewModel.value.connectionLost, isTrue);
    expect(find.text('Connexion au téléphone perdue'), findsOneWidget);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });

  testWidgets('reconnects and refreshes freshness state immediately', (
    tester,
  ) async {
    final client = _FakeNativeWatchBridgeClient();
    final viewModel = WatchSessionViewModel(
      nativeClient: client,
      staleProjectionThreshold: const Duration(milliseconds: 100),
      connectionLostThreshold: const Duration(milliseconds: 220),
    );

    await tester.pumpWidget(
      MaterialApp(
        theme: watchTheme(),
        home: WatchSessionScreen(viewModel: viewModel),
      ),
    );

    client.emitProjection(
      _expiringProjection(expiresIn: const Duration(seconds: 3)),
    );
    await tester.pump(const Duration(milliseconds: 240));
    expect(viewModel.value.connectionLost, isTrue);

    client.emitConnection(const WatchBridgeConnectionEvent(isReachable: true));
    await tester.pump();

    expect(viewModel.value.connectionLost, isFalse);
    expect(viewModel.value.staleProjection, isTrue);
    expect(client.resyncRequests, greaterThanOrEqualTo(2));

    client.emitProjection(
      _expiringProjection(expiresIn: const Duration(seconds: 3)),
    );
    await tester.pump();

    expect(viewModel.value.staleProjection, isFalse);
    expect(viewModel.value.connectionLost, isFalse);

    await tester.pumpWidget(const SizedBox.shrink());
    viewModel.dispose();
  });
}

final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
  final _projectionController =
      StreamController<WatchSessionProjection>.broadcast();
  final _sensorSampleController =
      StreamController<WatchSensorSample>.broadcast();
  final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
  final _alertController = StreamController<WatchAlertEnvelope>.broadcast();
  final _connectionController =
      StreamController<WatchBridgeConnectionEvent>.broadcast();

  var resyncRequests = 0;
  var capabilityRefreshRequests = 0;
  var invalidatedProjectionCount = 0;
  final sentCommands = <WatchCommandEnvelope>[];

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

  @override
  Stream<WatchSensorSample> get sensorSamples => _sensorSampleController.stream;

  @override
  Stream<WatchCommandAckEvent> get acks => _ackController.stream;

  @override
  Stream<WatchAlertEnvelope> get alerts => _alertController.stream;

  @override
  Stream<WatchBridgeConnectionEvent> get connectionEvents =>
      _connectionController.stream;

  void emitProjection(WatchSessionProjection projection) {
    _projectionController.add(projection);
  }

  void emitSensorSample(WatchSensorSample sample) {
    _sensorSampleController.add(sample);
  }

  void emitAck(WatchCommandAckEvent ack) {
    _ackController.add(ack);
  }

  void emitAlert(WatchAlertEnvelope alert) {
    _alertController.add(alert);
  }

  void emitConnection(WatchBridgeConnectionEvent event) {
    _connectionController.add(event);
  }

  @override
  Future<void> requestCapabilityRefresh() async {
    capabilityRefreshRequests += 1;
  }

  @override
  Future<void> invalidateActiveProjection() async {
    invalidatedProjectionCount += 1;
  }

  @override
  Future<void> requestResync() async {
    resyncRequests += 1;
  }

  @override
  Future<void> sendCommand(WatchCommandEnvelope command) async {
    sentCommands.add(command);
  }
}

WatchSessionProjection _runningProjection() {
  final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 1,
    projectedAtEpochMs: projectedAt,
    expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 2,
    seriesTotal: 4,
    exerciseName: 'Squat jump',
    statusLabel: 'Séance active',
    primaryAction: WatchPrimaryAction.pauseSession,
    dominantTimer: _runningStepTimer(),
    secondaryTimers: const [
      WatchTimerProjection(
        kind: WatchTimerKind.setTimer,
        label: 'Série',
        displayMode: WatchTimerDisplayMode.elapsed,
        runState: WatchTimerRunState.running,
        referenceEpochMs: 0,
        accumulatedMs: 45000,
      ),
    ],
    secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
  );
}

WatchSessionProjection _expiringProjection({required Duration expiresIn}) {
  final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 99,
    projectedAtEpochMs: nowMs,
    expiresAtEpochMs: nowMs + expiresIn.inMilliseconds,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Squat jump',
    statusLabel: 'Chrono étape',
    primaryAction: WatchPrimaryAction.pauseSession,
  );
}

WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
  return WatchSessionProjection(
    deviceSessionId: '',
    revision: 0,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.noActiveSession,
    phoneReachable: phoneReachable,
    seriesIndex: 0,
    seriesTotal: 0,
    exerciseName: '',
    statusLabel: 'Aucune séance',
    primaryAction: WatchPrimaryAction.none,
  );
}

WatchSessionProjection _readyProjection() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 1,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.ready,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Lancers francs',
    stepName: 'Routine de tir',
    statusLabel: 'Prêt à démarrer',
    primaryAction: WatchPrimaryAction.startCurrentExercise,
    dominantTimer: const WatchTimerProjection(
      kind: WatchTimerKind.step,
      label: 'Chrono étape',
      displayMode: WatchTimerDisplayMode.countdown,
      runState: WatchTimerRunState.stopped,
      referenceEpochMs: 0,
      accumulatedMs: 0,
      targetMs: 30000,
    ),
  );
}

WatchSessionProjection _repsStepProjection() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 7,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Pompes',
    stepIndex: 1,
    stepTotal: 2,
    stepName: 'Pompes strictes',
    stepType: WatchStepType.reps,
    stepTargetValue: 12,
    primaryAction: WatchPrimaryAction.pauseSession,
    secondaryActions: const [WatchSecondaryAction.skipCurrentStep],
  );
}

WatchSessionProjection _secondaryRestActionProjection() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 4,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 2,
    seriesTotal: 4,
    exerciseName: 'Squat jump',
    statusLabel: 'Séance active',
    primaryAction: WatchPrimaryAction.pauseSession,
    secondaryActions: const [WatchSecondaryAction.skipCurrentRest],
  );
}

WatchSessionProjection _setTimerDominantProjection() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 6,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 2,
    seriesTotal: 4,
    exerciseName: 'Squat jump',
    statusLabel: 'Séance active',
    primaryAction: WatchPrimaryAction.pauseSession,
    dominantTimer: const WatchTimerProjection(
      kind: WatchTimerKind.setTimer,
      label: 'Série',
      displayMode: WatchTimerDisplayMode.elapsed,
      runState: WatchTimerRunState.running,
      referenceEpochMs: 0,
      accumulatedMs: 45000,
    ),
    secondaryTimers: [_runningStepTimer()],
    secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
  );
}

void _expectActionsPageVisible(WidgetTester tester) {
  expect(
    tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
    lessThan(96),
  );
}

void _expectSessionPageVisible(WidgetTester tester) {
  expect(
    tester.getTopLeft(find.byKey(const ValueKey('watch-session-page'))).dx,
    lessThan(96),
  );
}

WatchSessionProjection _manualScoreProjection({
  Duration expiresIn = const Duration(seconds: 12),
}) {
  final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 2,
    projectedAtEpochMs: projectedAt,
    expiresAtEpochMs: projectedAt + expiresIn.inMilliseconds,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Lancers francs',
    stepName: 'Routine de tir',
    statusLabel: 'Score manuel',
    primaryAction: WatchPrimaryAction.pauseSession,
    hasManualScore: true,
    currentManualScoreValue: 3,
    canDecrementScore: true,
    manualScoreTargetValue: 8,
    manualScoreTargetLabel: 'Cible',
    manualScoreScope: WatchManualScoreScope.series,
  );
}

WatchSessionProjection _manualScoreProjectionWithTimer() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 3,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Lancers francs',
    stepName: 'Routine de tir',
    statusLabel: 'Score manuel',
    primaryAction: WatchPrimaryAction.pauseSession,
    dominantTimer: _runningStepTimer(),
    hasManualScore: true,
    currentManualScoreValue: 3,
    canDecrementScore: true,
    manualScoreTargetValue: 8,
    manualScoreTargetLabel: 'Cible',
    manualScoreScope: WatchManualScoreScope.step,
  );
}

WatchSessionProjection _manualScoreProjectionWithRepsTarget() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 4,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Pompes tempo',
    stepName: 'Score libre',
    statusLabel: 'Score manuel',
    primaryAction: WatchPrimaryAction.pauseSession,
    hasManualScore: true,
    currentManualScoreValue: 3,
    canDecrementScore: true,
    manualScoreTargetValue: 8,
    manualScoreTargetLabel: 'Cible',
    manualScoreRepsTargetValue: 10,
    manualScoreScope: WatchManualScoreScope.step,
  );
}

WatchSessionProjection _restProjection() {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: 5,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.restRunning,
    phoneReachable: true,
    seriesIndex: 2,
    seriesTotal: 4,
    exerciseName: 'Squat jump',
    nextExerciseName: 'Gainage latéral',
    statusLabel: 'Repos',
    primaryAction: WatchPrimaryAction.pauseSession,
    dominantTimer: WatchTimerProjection(
      kind: WatchTimerKind.rest,
      label: 'Repos',
      displayMode: WatchTimerDisplayMode.countdown,
      runState: WatchTimerRunState.running,
      referenceEpochMs: DateTime.now()
          .toUtc()
          .add(const Duration(minutes: 1))
          .millisecondsSinceEpoch,
      accumulatedMs: 30000,
      startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
      targetMs: 60000,
    ),
  );
}

WatchSessionProjection _countdownProjection({required int accumulatedMs}) {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: accumulatedMs,
    projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Gainage',
    statusLabel: 'Chrono étape',
    primaryAction: WatchPrimaryAction.pauseSession,
    dominantTimer: WatchTimerProjection(
      kind: WatchTimerKind.step,
      label: 'Chrono étape',
      displayMode: WatchTimerDisplayMode.countdown,
      runState: WatchTimerRunState.running,
      referenceEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
      accumulatedMs: accumulatedMs,
      startedAtEpochMs: null,
      targetMs: 30000,
    ),
  );
}

WatchSessionProjection _liveCountdownProjection({
  required int remainingMs,
  required int nowMs,
}) {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: remainingMs,
    projectedAtEpochMs: nowMs,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Gainage',
    statusLabel: 'Chrono étape',
    primaryAction: WatchPrimaryAction.pauseSession,
    dominantTimer: WatchTimerProjection(
      kind: WatchTimerKind.step,
      label: 'Chrono étape',
      displayMode: WatchTimerDisplayMode.countdown,
      runState: WatchTimerRunState.running,
      referenceEpochMs: nowMs,
      accumulatedMs: 30000 - remainingMs,
      startedAtEpochMs: nowMs,
      targetMs: 30000,
    ),
  );
}

WatchSessionProjection _pausedCountdownProjection({required int remainingMs}) {
  final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: remainingMs,
    projectedAtEpochMs: nowMs,
    phase: WatchSessionPhase.running,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 3,
    exerciseName: 'Gainage',
    statusLabel: 'Chrono étape',
    primaryAction: WatchPrimaryAction.resumeSession,
    dominantTimer: WatchTimerProjection(
      kind: WatchTimerKind.step,
      label: 'Chrono étape',
      displayMode: WatchTimerDisplayMode.countdown,
      runState: WatchTimerRunState.paused,
      referenceEpochMs: nowMs,
      accumulatedMs: 30000 - remainingMs,
      startedAtEpochMs: null,
      targetMs: 30000,
    ),
  );
}

WatchTimerProjection _runningStepTimer() {
  return WatchTimerProjection(
    kind: WatchTimerKind.step,
    label: 'Chrono étape',
    displayMode: WatchTimerDisplayMode.elapsed,
    runState: WatchTimerRunState.running,
    referenceEpochMs: DateTime.now()
        .toUtc()
        .add(const Duration(minutes: 1))
        .millisecondsSinceEpoch,
    accumulatedMs: 134000,
    startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
    targetMs: 180000,
  );
}
