import 'dart:convert';
import 'dart:math' as math;

import 'package:flutter/material.dart';

import '../application/application.dart';
import '../domain/domain.dart';
import 'theme.dart';
import 'workout_execution_screen.dart';

final class HistoryListScreen extends StatefulWidget {
  const HistoryListScreen({
    required this.historyUseCases,
    required this.workoutTemplateUseCases,
    required this.activeUseCases,
    required this.closeUseCase,
    this.mediaUseCases,
    this.stepUseCases,
    this.sensorUseCases,
    this.telemetryUseCases,
    this.performanceReferenceUseCase,
    this.onOpenProgression,
    this.watchAlertPublisher,
    super.key,
  });

  final WorkoutHistoryUseCases historyUseCases;
  final WorkoutTemplateUseCases workoutTemplateUseCases;
  final ActiveWorkoutSessionUseCases activeUseCases;
  final ActiveExerciseStepUseCases? stepUseCases;
  final ActiveWorkoutSensorUseCases? sensorUseCases;
  final WorkoutTelemetryUseCases? telemetryUseCases;
  final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
  final CloseWorkoutSessionUseCase closeUseCase;
  final MediaUseCases? mediaUseCases;
  final VoidCallback? onOpenProgression;
  final WatchAlertPublisher? watchAlertPublisher;

  @override
  State<HistoryListScreen> createState() => _HistoryListScreenState();
}

final class _HistoryListScreenState extends State<HistoryListScreen> {
  late Future<List<WorkoutHistory>> _histories;

  @override
  void initState() {
    super.initState();
    _histories = widget.historyUseCases.listActive();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Historique'),
        actions: [
          if (widget.onOpenProgression != null)
            TextButton(
              onPressed: widget.onOpenProgression,
              child: const Text('Progression'),
            ),
        ],
      ),
      body: FutureBuilder<List<WorkoutHistory>>(
        future: _histories,
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return const Center(child: CircularProgressIndicator());
          }
          final histories = snapshot.data ?? const <WorkoutHistory>[];
          if (histories.isEmpty) {
            return const _CenteredMessage(
              title: 'Aucune séance terminée',
              message: 'Les séances terminées apparaîtront ici.',
            );
          }
          final groups = groupHistoriesByPeriod(histories, DateTime.now());
          return ListView(
            children: [
              for (final group in groups.entries) ...[
                Padding(
                  padding: const EdgeInsets.fromLTRB(16, 20, 16, 8),
                  child: Text(
                    group.key,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ),
                for (final history in group.value)
                  CourtBlazerAccentPanel(
                    margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
                    child: ListTile(
                      title: Text(history.nameSnapshot),
                      subtitle: Text(
                        '${_formatDateTime(history.startedAt)} · '
                        '${_formatDurationMs(history.totalActiveMs)} · '
                        '${_historySummary(history)}',
                      ),
                      trailing: const Icon(Icons.chevron_right),
                      onTap: () => _openDetail(history),
                    ),
                  ),
              ],
            ],
          );
        },
      ),
    );
  }

  Future<void> _openDetail(WorkoutHistory history) async {
    final deleted = await Navigator.of(context).push<bool>(
      MaterialPageRoute(
        builder: (context) => HistoryDetailScreen(
          history: history,
          historyUseCases: widget.historyUseCases,
          workoutTemplateUseCases: widget.workoutTemplateUseCases,
          activeUseCases: widget.activeUseCases,
          stepUseCases: widget.stepUseCases,
          sensorUseCases: widget.sensorUseCases,
          telemetryUseCases: widget.telemetryUseCases,
          performanceReferenceUseCase: widget.performanceReferenceUseCase,
          closeUseCase: widget.closeUseCase,
          mediaUseCases: widget.mediaUseCases,
          watchAlertPublisher: widget.watchAlertPublisher,
        ),
      ),
    );
    if (deleted == true) {
      setState(() {
        _histories = widget.historyUseCases.listActive();
      });
    }
  }
}

final class HistoryDetailScreen extends StatelessWidget {
  const HistoryDetailScreen({
    required this.history,
    required this.historyUseCases,
    required this.workoutTemplateUseCases,
    required this.activeUseCases,
    required this.closeUseCase,
    this.mediaUseCases,
    this.stepUseCases,
    this.sensorUseCases,
    this.telemetryUseCases,
    this.performanceReferenceUseCase,
    this.watchAlertPublisher,
    super.key,
  });

  final WorkoutHistory history;
  final WorkoutHistoryUseCases historyUseCases;
  final WorkoutTemplateUseCases workoutTemplateUseCases;
  final ActiveWorkoutSessionUseCases activeUseCases;
  final ActiveExerciseStepUseCases? stepUseCases;
  final ActiveWorkoutSensorUseCases? sensorUseCases;
  final WorkoutTelemetryUseCases? telemetryUseCases;
  final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
  final CloseWorkoutSessionUseCase closeUseCase;
  final MediaUseCases? mediaUseCases;
  final WatchAlertPublisher? watchAlertPublisher;

  @override
  Widget build(BuildContext context) {
    final detail = HistoryDetailData.fromHistory(history);
    return Scaffold(
      appBar: AppBar(title: Text(history.nameSnapshot)),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text(_formatDateTime(history.startedAt)),
          const SizedBox(height: 4),
          Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
          if (_hasWatchStats(history)) ...[
            const SizedBox(height: 16),
            _HistoryWatchStatsSummary(
              history: history,
              telemetryUseCases: telemetryUseCases,
            ),
          ],
          const SizedBox(height: 16),
          for (final program in detail.programs) ...[
            Text(program.name, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            for (final exercise in program.exercises) ...[
              Text(
                exercise.name,
                style: Theme.of(context).textTheme.titleSmall,
              ),
              for (final set in exercise.sets)
                Padding(
                  padding: const EdgeInsets.only(bottom: 8),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      ListTile(
                        dense: true,
                        contentPadding: EdgeInsets.zero,
                        title: Text('Série ${set.setIndex + 1}'),
                        subtitle: Text(set.summary),
                      ),
                      if (set.stepPassages.isNotEmpty)
                        _HistoryStepResultsSection(passages: set.stepPassages),
                    ],
                  ),
                ),
            ],
            const SizedBox(height: 12),
          ],
          const SizedBox(height: 16),
          FilledButton(
            onPressed: () => _restart(context),
            child: const Text('Relancer cette séance'),
          ),
          const SizedBox(height: 8),
          OutlinedButton(
            onPressed: () => _confirmDelete(context),
            child: const Text("Supprimer de l'historique"),
          ),
        ],
      ),
    );
  }

  Future<void> _restart(BuildContext context) async {
    final hasBlockingOpenSession = await _hasBlockingOpenSession();
    if (!context.mounted) return;
    if (hasBlockingOpenSession) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text(
            'Une séance est déjà en cours. Termine-la ou reprends-la avant '
            'd’en lancer une nouvelle.',
          ),
        ),
      );
      return;
    }
    ActiveWorkoutSession session;
    final sourceId = history.sourceWorkoutTemplateId;
    try {
      if (sourceId != null &&
          await workoutTemplateUseCases.findById(sourceId) != null) {
        session = await activeUseCases.startFromTemplate(sourceId);
      } else {
        session = await activeUseCases.startFromHistory(history);
        if (!context.mounted) return;
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text(
              "La séance originale n'existe plus. Une copie va être utilisée.",
            ),
          ),
        );
      }
    } on DomainException {
      if (!context.mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text(
            'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
          ),
        ),
      );
      return;
    }
    if (!context.mounted) return;
    await Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => WorkoutExecutionScreen(
          initialSession: session,
          activeUseCases: activeUseCases,
          stepUseCases: stepUseCases,
          sensorUseCases: sensorUseCases,
          closeUseCase: closeUseCase,
          historyUseCases: historyUseCases,
          workoutTemplateUseCases: workoutTemplateUseCases,
          performanceReferenceUseCase: performanceReferenceUseCase,
          mediaUseCases: mediaUseCases,
          watchAlertPublisher: watchAlertPublisher,
        ),
      ),
    );
  }

  Future<bool> _hasBlockingOpenSession() async {
    final openSession = await activeUseCases.findOpen();
    if (openSession == null) {
      return false;
    }
    if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
      return true;
    }
    try {
      await activeUseCases.abandon(openSession.metadata.id);
    } on DomainException {
      // Legacy invalid sessions must not block a restart.
    }
    return false;
  }

  Future<void> _confirmDelete(BuildContext context) async {
    final confirmed = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text("Supprimer de l'historique ?"),
        content: const Text('Cette action est définitive.'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('Annuler'),
          ),
          FilledButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('Supprimer'),
          ),
        ],
      ),
    );
    if (confirmed != true) return;
    await historyUseCases.delete(history.metadata.id);
    if (!context.mounted) return;
    Navigator.of(context).pop(true);
  }
}

final class _HistoryWatchStatsSummary extends StatelessWidget {
  const _HistoryWatchStatsSummary({
    required this.history,
    required this.telemetryUseCases,
  });

  final WorkoutHistory history;
  final WorkoutTelemetryUseCases? telemetryUseCases;

  @override
  Widget build(BuildContext context) {
    final minHeartRate = history.minHeartRateBpm;
    final averageHeartRate = history.averageHeartRateBpm;
    final maxHeartRate = history.maxHeartRateBpm;
    final distance = history.totalDistanceMeters;
    final calories = history.totalCaloriesKcal;
    return CourtBlazerAccentPanel(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text('Stats montre', style: Theme.of(context).textTheme.titleSmall),
          if (telemetryUseCases != null) ...[
            const SizedBox(height: 12),
            _HistoryWatchTelemetryGraph(
              history: history,
              telemetryUseCases: telemetryUseCases!,
            ),
          ],
          const SizedBox(height: 8),
          if (minHeartRate != null ||
              averageHeartRate != null ||
              maxHeartRate != null) ...[
            Text('Fréquence cardiaque'),
            const SizedBox(height: 8),
            Row(
              children: [
                if (minHeartRate != null)
                  Expanded(
                    child: _HistoryWatchMetric(
                      label: 'Min',
                      value: '$minHeartRate bpm',
                    ),
                  ),
                if (minHeartRate != null && averageHeartRate != null)
                  const SizedBox(width: 8),
                if (averageHeartRate != null)
                  Expanded(
                    child: _HistoryWatchMetric(
                      label: 'Moyenne',
                      value: '${averageHeartRate.round()} bpm',
                    ),
                  ),
                if ((minHeartRate != null || averageHeartRate != null) &&
                    maxHeartRate != null)
                  const SizedBox(width: 8),
                if (maxHeartRate != null)
                  Expanded(
                    child: _HistoryWatchMetric(
                      label: 'Max',
                      value: '$maxHeartRate bpm',
                    ),
                  ),
              ],
            ),
          ],
          if (distance != null || calories != null) ...[
            const SizedBox(height: 12),
            Row(
              children: [
                if (distance != null)
                  Expanded(
                    child: _HistoryWatchMetric(
                      label: 'Distance',
                      value: _formatHistoryDistance(distance),
                    ),
                  ),
                if (distance != null && calories != null)
                  const SizedBox(width: 8),
                if (calories != null)
                  Expanded(
                    child: _HistoryWatchMetric(
                      label: 'Calories',
                      value: '${calories.round()} kcal',
                    ),
                  ),
              ],
            ),
          ],
        ],
      ),
    );
  }
}

enum _TelemetryScope { step, set, exercise, session }

enum _TelemetryMetric { heartRate, distance, calories }

final class _HistoryWatchTelemetryGraph extends StatefulWidget {
  const _HistoryWatchTelemetryGraph({
    required this.history,
    required this.telemetryUseCases,
  });

  final WorkoutHistory history;
  final WorkoutTelemetryUseCases telemetryUseCases;

  @override
  State<_HistoryWatchTelemetryGraph> createState() =>
      _HistoryWatchTelemetryGraphState();
}

final class _HistoryWatchTelemetryGraphState
    extends State<_HistoryWatchTelemetryGraph> {
  _TelemetryScope _scope = _TelemetryScope.session;
  _TelemetryMetric _metric = _TelemetryMetric.heartRate;
  int? _selectedExerciseOrdinal;
  int? _selectedSetOrdinal;
  int? _selectedStepOrdinal;

  @override
  Widget build(BuildContext context) {
    final tokens = courtBlazerTokensOf(context);
    return FutureBuilder<_TelemetryGraphState>(
      future: _loadGraphState(),
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const SizedBox(
            height: 280,
            child: Center(child: CircularProgressIndicator()),
          );
        }
        final state = snapshot.data ?? _TelemetryGraphState.empty(_scope);
        final metric = _metricSpec(_metric);
        final metricPoints = state.points
            .where((point) => metric.read(point) != null)
            .toList(growable: false);
        final displayValue = _displayValueForMetric(
          state,
          metric,
          metricPoints,
        );
        final selectedExercise = state.selectedExercise;
        final selectedSet = state.selectedSet;
        final selectedStep = state.selectedStep;
        return Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: SegmentedButton<_TelemetryMetric>(
                showSelectedIcon: false,
                segments: const [
                  ButtonSegment(
                    value: _TelemetryMetric.heartRate,
                    label: Text('FC'),
                  ),
                  ButtonSegment(
                    value: _TelemetryMetric.distance,
                    label: Text('Distance'),
                  ),
                  ButtonSegment(
                    value: _TelemetryMetric.calories,
                    label: Text('Calories'),
                  ),
                ],
                selected: {_metric},
                onSelectionChanged: (selection) =>
                    setState(() => _metric = selection.single),
              ),
            ),
            const SizedBox(height: 12),
            SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: SegmentedButton<_TelemetryScope>(
                showSelectedIcon: false,
                segments: const [
                  ButtonSegment(
                    value: _TelemetryScope.session,
                    label: Text('Séance'),
                  ),
                  ButtonSegment(
                    value: _TelemetryScope.exercise,
                    label: Text('Exercice'),
                  ),
                  ButtonSegment(
                    value: _TelemetryScope.set,
                    label: Text('Série'),
                  ),
                  ButtonSegment(
                    value: _TelemetryScope.step,
                    label: Text('Étape'),
                  ),
                ],
                selected: {_scope},
                onSelectionChanged: (selection) {
                  setState(() {
                    _scope = selection.single;
                    _selectedExerciseOrdinal = null;
                    _selectedSetOrdinal = null;
                    _selectedStepOrdinal = null;
                  });
                },
              ),
            ),
            if (_scope != _TelemetryScope.session) ...[
              const SizedBox(height: 12),
              _TelemetryInstanceSelectors(
                scope: _scope,
                exerciseInstances: state.exerciseInstances,
                setInstances: state.setInstancesForSelectedExercise,
                stepInstances: state.stepInstancesForSelectedSet,
                selectedExercise: selectedExercise,
                selectedSet: selectedSet,
                selectedStep: selectedStep,
                onExerciseChanged: (descriptor) {
                  setState(() {
                    _selectedExerciseOrdinal = descriptor.ordinal;
                    _selectedSetOrdinal = null;
                    _selectedStepOrdinal = null;
                  });
                },
                onSetChanged: (descriptor) {
                  setState(() {
                    _selectedSetOrdinal = descriptor.ordinal;
                    _selectedStepOrdinal = null;
                  });
                },
                onStepChanged: (descriptor) {
                  setState(() {
                    _selectedStepOrdinal = descriptor.ordinal;
                  });
                },
              ),
            ],
            const SizedBox(height: 12),
            Text(
              '${metric.title} · ${state.scopeLabel} · '
              '${_formatDurationMs(state.durationMs)}',
              style: Theme.of(context).textTheme.labelLarge,
            ),
            const SizedBox(height: 8),
            SizedBox(
              height: 220,
              child: metricPoints.length < 2
                  ? DecoratedBox(
                      decoration: BoxDecoration(
                        border: Border.all(color: tokens.border),
                        borderRadius: BorderRadius.circular(6),
                      ),
                      child: Center(
                        child: Text(
                          'Aucune donnée exploitable pour '
                          '${metric.title.toLowerCase()} sur ce scope.',
                          textAlign: TextAlign.center,
                        ),
                      ),
                    )
                  : Semantics(
                      label:
                          'Graphique ${metric.title}, temps en abscisse, '
                          '${metric.unit} en ordonnée',
                      key: const ValueKey('history-watch-stats-graph'),
                      child: CustomPaint(
                        painter: _TelemetryChartPainter(
                          points: metricPoints,
                          readValue: metric.read,
                          valueFormatter: metric.axisFormat,
                          unit: metric.unit,
                          color: Theme.of(context).colorScheme.primary,
                          borderColor: tokens.border,
                          labelColor: tokens.mutedText,
                          minGuide: _metric == _TelemetryMetric.heartRate
                              ? state.minHeartRateBpm?.toDouble()
                              : null,
                          maxGuide: _metric == _TelemetryMetric.heartRate
                              ? state.maxHeartRateBpm?.toDouble()
                              : null,
                          markers: state.markers,
                        ),
                      ),
                    ),
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 12,
              runSpacing: 8,
              children: [
                _HistoryWatchCompactIndicator(
                  label: 'Période',
                  value: _formatDurationMs(state.durationMs),
                ),
                _HistoryWatchCompactIndicator(
                  label: 'Focale',
                  value: state.scopeLabel,
                ),
                _HistoryWatchCompactIndicator(
                  label: metric.compactLabel,
                  value: displayValue == null
                      ? 'Indisponible'
                      : metric.format(displayValue),
                ),
                if (_metric == _TelemetryMetric.heartRate &&
                    state.minHeartRateBpm != null)
                  _HistoryWatchCompactIndicator(
                    label: 'Min',
                    value: '${state.minHeartRateBpm} bpm',
                  ),
                if (_metric == _TelemetryMetric.heartRate &&
                    state.maxHeartRateBpm != null)
                  _HistoryWatchCompactIndicator(
                    label: 'Max',
                    value: '${state.maxHeartRateBpm} bpm',
                  ),
                _HistoryWatchCompactIndicator(
                  label: 'Points',
                  value: state.scopeIndicator,
                ),
                if (state.markers.isNotEmpty)
                  _HistoryWatchCompactIndicator(
                    label: 'Repères',
                    value: '${state.markers.length}',
                  ),
              ],
            ),
          ],
        );
      },
    );
  }

  Future<_TelemetryGraphState> _loadGraphState() async {
    final aggregateScope = _aggregateScope(_scope);
    final exerciseInstances = await widget.telemetryUseCases
        .listScopeInstancesForHistory(
          history: widget.history,
          scope: WorkoutTelemetryAggregateScope.exercise,
        );
    final setInstances = await widget.telemetryUseCases
        .listScopeInstancesForHistory(
          history: widget.history,
          scope: WorkoutTelemetryAggregateScope.set,
        );
    final stepInstances = await widget.telemetryUseCases
        .listScopeInstancesForHistory(
          history: widget.history,
          scope: WorkoutTelemetryAggregateScope.step,
        );

    final selectedExercise = _selectedOrFirst(
      exerciseInstances,
      _selectedExerciseOrdinal,
    );
    final selectedSet = _selectedOrFirst(
      _filterChildren(
        setInstances,
        programIndex: selectedExercise?.programIndex,
        exerciseIndex: selectedExercise?.exerciseIndex,
      ),
      _selectedSetOrdinal,
    );
    final selectedStep = _selectedOrFirst(
      _filterChildren(
        stepInstances,
        programIndex: selectedExercise?.programIndex,
        exerciseIndex: selectedExercise?.exerciseIndex,
        setIndex: selectedSet?.setIndex,
      ),
      _selectedStepOrdinal,
    );
    final selectedInstance = switch (_scope) {
      _TelemetryScope.session => null,
      _TelemetryScope.exercise => selectedExercise,
      _TelemetryScope.set => selectedSet,
      _TelemetryScope.step => selectedStep,
    };
    if (_scope != _TelemetryScope.session && selectedInstance == null) {
      return _TelemetryGraphState.empty(
        _scope,
        exerciseInstances: exerciseInstances,
        setInstances: setInstances,
        stepInstances: stepInstances,
      );
    }
    final series = await widget.telemetryUseCases.readGraphSeriesForHistory(
      history: widget.history,
      scope: aggregateScope,
      programIndex: selectedInstance?.programIndex,
      exerciseIndex: selectedInstance?.exerciseIndex,
      setIndex: selectedInstance?.setIndex,
      passageIndex: selectedInstance?.passageIndex,
      stepIndex: selectedInstance?.stepIndex,
    );
    final markers = await widget.telemetryUseCases.readScopeMarkersForHistory(
      history: widget.history,
      scope: aggregateScope,
      programIndex: selectedInstance?.programIndex,
      exerciseIndex: selectedInstance?.exerciseIndex,
      setIndex: selectedInstance?.setIndex,
      passageIndex: selectedInstance?.passageIndex,
      stepIndex: selectedInstance?.stepIndex,
    );
    return _TelemetryGraphState.fromSeries(
      series,
      markers: markers,
      exerciseInstances: exerciseInstances,
      setInstances: setInstances,
      stepInstances: stepInstances,
      selectedExercise: selectedExercise,
      selectedSet: selectedSet,
      selectedStep: selectedStep,
    );
  }
}

final class _TelemetryInstanceSelectors extends StatelessWidget {
  const _TelemetryInstanceSelectors({
    required this.scope,
    required this.exerciseInstances,
    required this.setInstances,
    required this.stepInstances,
    required this.selectedExercise,
    required this.selectedSet,
    required this.selectedStep,
    required this.onExerciseChanged,
    required this.onSetChanged,
    required this.onStepChanged,
  });

  final _TelemetryScope scope;
  final List<ScopeInstanceDescriptor> exerciseInstances;
  final List<ScopeInstanceDescriptor> setInstances;
  final List<ScopeInstanceDescriptor> stepInstances;
  final ScopeInstanceDescriptor? selectedExercise;
  final ScopeInstanceDescriptor? selectedSet;
  final ScopeInstanceDescriptor? selectedStep;
  final ValueChanged<ScopeInstanceDescriptor> onExerciseChanged;
  final ValueChanged<ScopeInstanceDescriptor> onSetChanged;
  final ValueChanged<ScopeInstanceDescriptor> onStepChanged;

  @override
  Widget build(BuildContext context) {
    final children = <Widget>[
      _TelemetryInstanceDropdown(
        label: 'Exercice',
        instances: exerciseInstances,
        selected: selectedExercise,
        format: (instance) => 'Ex. ${instance.ordinal}',
        onChanged: onExerciseChanged,
      ),
    ];
    if (scope == _TelemetryScope.set || scope == _TelemetryScope.step) {
      children.add(
        _TelemetryInstanceDropdown(
          label: 'Série',
          instances: setInstances,
          selected: selectedSet,
          format: (instance) => 'Série ${instance.ordinal}',
          onChanged: onSetChanged,
        ),
      );
    }
    if (scope == _TelemetryScope.step) {
      children.add(
        _TelemetryInstanceDropdown(
          label: 'Étape',
          instances: stepInstances,
          selected: selectedStep,
          format: (instance) => 'Étape ${instance.ordinal}',
          onChanged: onStepChanged,
        ),
      );
    }
    return Wrap(spacing: 8, runSpacing: 8, children: children);
  }
}

final class _TelemetryInstanceDropdown extends StatelessWidget {
  const _TelemetryInstanceDropdown({
    required this.label,
    required this.instances,
    required this.selected,
    required this.format,
    required this.onChanged,
  });

  final String label;
  final List<ScopeInstanceDescriptor> instances;
  final ScopeInstanceDescriptor? selected;
  final String Function(ScopeInstanceDescriptor instance) format;
  final ValueChanged<ScopeInstanceDescriptor> onChanged;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 150,
      child: DropdownButtonFormField<int>(
        key: ValueKey(
          '$label:${selected?.ordinal}:'
          '${instances.map((instance) => instance.ordinal).join(',')}',
        ),
        isExpanded: true,
        decoration: InputDecoration(
          labelText: label,
          isDense: true,
          contentPadding: const EdgeInsets.symmetric(
            horizontal: 12,
            vertical: 10,
          ),
        ),
        initialValue: selected?.ordinal,
        items: [
          for (final instance in instances)
            DropdownMenuItem(
              value: instance.ordinal,
              child: Text(format(instance), overflow: TextOverflow.ellipsis),
            ),
        ],
        onChanged: instances.isEmpty
            ? null
            : (ordinal) {
                final instance = instances.firstWhere(
                  (entry) => entry.ordinal == ordinal,
                  orElse: () => instances.first,
                );
                onChanged(instance);
              },
      ),
    );
  }
}

ScopeInstanceDescriptor? _selectedOrFirst(
  List<ScopeInstanceDescriptor> instances,
  int? selectedOrdinal,
) {
  if (instances.isEmpty) {
    return null;
  }
  for (final instance in instances) {
    if (instance.ordinal == selectedOrdinal) {
      return instance;
    }
  }
  return instances.first;
}

List<ScopeInstanceDescriptor> _filterChildren(
  List<ScopeInstanceDescriptor> instances, {
  int? programIndex,
  int? exerciseIndex,
  int? setIndex,
}) {
  if (programIndex == null || exerciseIndex == null) {
    return const [];
  }
  return instances
      .where(
        (instance) =>
            instance.programIndex == programIndex &&
            instance.exerciseIndex == exerciseIndex &&
            (setIndex == null || instance.setIndex == setIndex),
      )
      .toList(growable: false);
}

WorkoutTelemetryAggregateScope _aggregateScope(_TelemetryScope scope) {
  return switch (scope) {
    _TelemetryScope.step => WorkoutTelemetryAggregateScope.step,
    _TelemetryScope.set => WorkoutTelemetryAggregateScope.set,
    _TelemetryScope.exercise => WorkoutTelemetryAggregateScope.exercise,
    _TelemetryScope.session => WorkoutTelemetryAggregateScope.session,
  };
}

_TelemetryScope _presentationScope(WorkoutTelemetryAggregateScope scope) {
  return switch (scope) {
    WorkoutTelemetryAggregateScope.step => _TelemetryScope.step,
    WorkoutTelemetryAggregateScope.set => _TelemetryScope.set,
    WorkoutTelemetryAggregateScope.exercise => _TelemetryScope.exercise,
    WorkoutTelemetryAggregateScope.session => _TelemetryScope.session,
  };
}

String _scopeLabel(_TelemetryScope scope) {
  return switch (scope) {
    _TelemetryScope.step => 'Étape',
    _TelemetryScope.set => 'Série',
    _TelemetryScope.exercise => 'Exercice',
    _TelemetryScope.session => 'Séance',
  };
}

final class _TelemetryGraphState {
  const _TelemetryGraphState({
    required this.scope,
    required this.points,
    required this.markers,
    required this.exerciseInstances,
    required this.setInstances,
    required this.stepInstances,
    this.selectedExercise,
    this.selectedSet,
    this.selectedStep,
    this.minHeartRateBpm,
    this.averageHeartRateBpm,
    this.maxHeartRateBpm,
  });

  factory _TelemetryGraphState.empty(
    _TelemetryScope scope, {
    List<ScopeInstanceDescriptor> exerciseInstances = const [],
    List<ScopeInstanceDescriptor> setInstances = const [],
    List<ScopeInstanceDescriptor> stepInstances = const [],
  }) {
    return _TelemetryGraphState(
      scope: _aggregateScope(scope),
      points: const [],
      markers: const [],
      exerciseInstances: exerciseInstances,
      setInstances: setInstances,
      stepInstances: stepInstances,
    );
  }

  factory _TelemetryGraphState.fromSeries(
    WorkoutTelemetryGraphSeries series, {
    required List<ScopeMarker> markers,
    required List<ScopeInstanceDescriptor> exerciseInstances,
    required List<ScopeInstanceDescriptor> setInstances,
    required List<ScopeInstanceDescriptor> stepInstances,
    ScopeInstanceDescriptor? selectedExercise,
    ScopeInstanceDescriptor? selectedSet,
    ScopeInstanceDescriptor? selectedStep,
  }) {
    final heartRates = series.points
        .map((point) => point.heartRateBpm)
        .whereType<int>()
        .toList(growable: false);
    return _TelemetryGraphState(
      scope: series.scope,
      points: series.points,
      markers: markers,
      exerciseInstances: exerciseInstances,
      setInstances: setInstances,
      stepInstances: stepInstances,
      selectedExercise: selectedExercise,
      selectedSet: selectedSet,
      selectedStep: selectedStep,
      minHeartRateBpm: series.minHeartRateBpm,
      averageHeartRateBpm: heartRates.isEmpty
          ? null
          : heartRates.reduce((left, right) => left + right) /
                heartRates.length,
      maxHeartRateBpm: series.maxHeartRateBpm,
    );
  }

  final WorkoutTelemetryAggregateScope scope;
  final List<WorkoutTelemetryGraphPoint> points;
  final List<ScopeMarker> markers;
  final List<ScopeInstanceDescriptor> exerciseInstances;
  final List<ScopeInstanceDescriptor> setInstances;
  final List<ScopeInstanceDescriptor> stepInstances;
  final ScopeInstanceDescriptor? selectedExercise;
  final ScopeInstanceDescriptor? selectedSet;
  final ScopeInstanceDescriptor? selectedStep;
  final int? minHeartRateBpm;
  final double? averageHeartRateBpm;
  final int? maxHeartRateBpm;

  List<ScopeInstanceDescriptor> get setInstancesForSelectedExercise {
    return _filterChildren(
      setInstances,
      programIndex: selectedExercise?.programIndex,
      exerciseIndex: selectedExercise?.exerciseIndex,
    );
  }

  List<ScopeInstanceDescriptor> get stepInstancesForSelectedSet {
    return _filterChildren(
      stepInstances,
      programIndex: selectedExercise?.programIndex,
      exerciseIndex: selectedExercise?.exerciseIndex,
      setIndex: selectedSet?.setIndex,
    );
  }

  String get scopeLabel {
    final label = _scopeLabel(_presentationScope(scope));
    final instance = switch (scope) {
      WorkoutTelemetryAggregateScope.session => null,
      WorkoutTelemetryAggregateScope.exercise => selectedExercise,
      WorkoutTelemetryAggregateScope.set => selectedSet,
      WorkoutTelemetryAggregateScope.step => selectedStep,
    };
    if (instance == null) {
      return label;
    }
    return '$label ${instance.ordinal}';
  }

  int get durationMs {
    if (points.isEmpty) return 0;
    return points.last.elapsedMs.clamp(0, double.infinity).toInt();
  }

  String get scopeIndicator {
    final parts = <String>[];
    if (points.any((point) => point.heartRateBpm != null)) parts.add('FC');
    if (points.any((point) => point.distanceMeters != null)) {
      parts.add('distance');
    }
    if (points.any((point) => point.caloriesKcal != null)) {
      parts.add('calories');
    }
    return parts.isEmpty
        ? '0 point'
        : '${points.length} pts · ${parts.join(' + ')}';
  }
}

final class _TelemetryMetricSpec {
  const _TelemetryMetricSpec({
    required this.title,
    required this.compactLabel,
    required this.unit,
    required this.read,
    required this.format,
    required this.axisFormat,
  });

  final String title;
  final String compactLabel;
  final String unit;
  final double? Function(WorkoutTelemetryGraphPoint point) read;
  final String Function(double value) format;
  final String Function(double value) axisFormat;
}

_TelemetryMetricSpec _metricSpec(_TelemetryMetric metric) {
  return switch (metric) {
    _TelemetryMetric.heartRate => _TelemetryMetricSpec(
      title: 'Fréquence cardiaque',
      compactLabel: 'Moyenne',
      unit: 'bpm',
      read: (point) => point.heartRateBpm?.toDouble(),
      format: (value) => '${value.round()} bpm',
      axisFormat: (value) => value.round().toString(),
    ),
    _TelemetryMetric.distance => _TelemetryMetricSpec(
      title: 'Distance',
      compactLabel: 'Distance',
      unit: 'm',
      read: (point) => point.distanceMeters,
      format: _formatHistoryDistance,
      axisFormat: (value) => value.round().toString(),
    ),
    _TelemetryMetric.calories => _TelemetryMetricSpec(
      title: 'Calories',
      compactLabel: 'Calories',
      unit: 'kcal',
      read: (point) => point.caloriesKcal,
      format: (value) => '${value.round()} kcal',
      axisFormat: (value) => value.round().toString(),
    ),
  };
}

double? _displayValueForMetric(
  _TelemetryGraphState state,
  _TelemetryMetricSpec metric,
  List<WorkoutTelemetryGraphPoint> points,
) {
  if (points.isEmpty) return null;
  if (metric.unit == 'bpm' && state.averageHeartRateBpm != null) {
    return state.averageHeartRateBpm;
  }
  return metric.read(points.last);
}

void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
  const dash = 6.0;
  const gap = 4.0;
  final delta = end - start;
  final distance = delta.distance;
  if (distance == 0) return;
  final direction = delta / distance;
  var travelled = 0.0;
  while (travelled < distance) {
    final segmentEnd = math.min(travelled + dash, distance);
    canvas.drawLine(
      start + direction * travelled,
      start + direction * segmentEnd,
      paint,
    );
    travelled += dash + gap;
  }
}

final class _HistoryWatchCompactIndicator extends StatelessWidget {
  const _HistoryWatchCompactIndicator({
    required this.label,
    required this.value,
  });

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    final tokens = courtBlazerTokensOf(context);
    return DecoratedBox(
      decoration: BoxDecoration(
        border: Border.all(color: tokens.border),
        borderRadius: BorderRadius.circular(6),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              label,
              style: Theme.of(
                context,
              ).textTheme.labelSmall?.copyWith(color: tokens.mutedText),
            ),
            Text(value, style: Theme.of(context).textTheme.labelMedium),
          ],
        ),
      ),
    );
  }
}

final class _TelemetryChartPainter extends CustomPainter {
  const _TelemetryChartPainter({
    required this.points,
    required this.readValue,
    required this.valueFormatter,
    required this.unit,
    required this.color,
    required this.borderColor,
    required this.labelColor,
    required this.markers,
    this.minGuide,
    this.maxGuide,
  });

  final List<WorkoutTelemetryGraphPoint> points;
  final double? Function(WorkoutTelemetryGraphPoint point) readValue;
  final String Function(double value) valueFormatter;
  final String unit;
  final Color color;
  final Color borderColor;
  final Color labelColor;
  final List<ScopeMarker> markers;
  final double? minGuide;
  final double? maxGuide;

  @override
  void paint(Canvas canvas, Size size) {
    final values = points
        .map((point) => (point: point, value: readValue(point)))
        .where((entry) => entry.value != null)
        .map((entry) => (point: entry.point, value: entry.value!))
        .toList(growable: false);
    if (values.length < 2) return;

    final textPainter = TextPainter(textDirection: TextDirection.ltr);
    final labelStyle = TextStyle(color: labelColor, fontSize: 10);
    final chart = Rect.fromLTRB(58, 14, size.width - 8, size.height - 32);
    final maxElapsed = math.max(1, values.last.point.elapsedMs);
    final rawMin = values.map((entry) => entry.value).reduce(math.min);
    final rawMax = values.map((entry) => entry.value).reduce(math.max);
    final guideValues = [?minGuide, ?maxGuide];
    final guideMin = guideValues.isEmpty
        ? rawMin
        : guideValues.reduce(math.min);
    final guideMax = guideValues.isEmpty
        ? rawMax
        : guideValues.reduce(math.max);
    final minValue = math.min(rawMin, guideMin);
    final maxValue = math.max(rawMax, guideMax);
    final range = math.max(1, maxValue - minValue);

    Offset pointFor(WorkoutTelemetryGraphPoint point, double value) {
      final xRatio = point.elapsedMs / maxElapsed;
      final yRatio = (value - minValue) / range;
      return Offset(
        chart.left + chart.width * xRatio.clamp(0, 1),
        chart.bottom - chart.height * yRatio.clamp(0, 1),
      );
    }

    final axisPaint = Paint()
      ..color = borderColor
      ..strokeWidth = 1;
    canvas.drawLine(chart.bottomLeft, chart.bottomRight, axisPaint);
    canvas.drawLine(chart.bottomLeft, chart.topLeft, axisPaint);

    for (final tick in [0.0, 0.5, 1.0]) {
      final y = chart.bottom - chart.height * tick;
      canvas.drawLine(
        Offset(chart.left - 3, y),
        Offset(chart.right, y),
        axisPaint,
      );
      final value = minValue + range * tick;
      _paintText(
        canvas,
        textPainter,
        '${valueFormatter(value)} $unit',
        Offset(0, y - 7),
        labelStyle,
      );
    }
    for (final tick in [0.0, 0.5, 1.0]) {
      final x = chart.left + chart.width * tick;
      canvas.drawLine(
        Offset(x, chart.bottom),
        Offset(x, chart.bottom + 3),
        axisPaint,
      );
      _paintText(
        canvas,
        textPainter,
        _formatAxisDuration((maxElapsed * tick).round()),
        Offset(x - 14, chart.bottom + 6),
        labelStyle,
      );
    }
    _paintText(
      canvas,
      textPainter,
      'temps m:ss',
      Offset(math.max(chart.left, chart.right - 56), size.height - 14),
      labelStyle,
    );
    _paintText(
      canvas,
      textPainter,
      unit,
      Offset(chart.left + 2, 0),
      labelStyle,
    );

    final guidePaint = Paint()
      ..color = color.withAlpha(140)
      ..strokeWidth = 1;
    for (final guide in guideValues) {
      final y = pointFor(values.first.point, guide).dy;
      _drawDashedLine(
        canvas,
        Offset(chart.left, y),
        Offset(chart.right, y),
        guidePaint,
      );
    }

    if (markers.isNotEmpty) {
      final markerPaint = Paint()
        ..color = labelColor.withAlpha(110)
        ..strokeWidth = 1;
      for (final marker in markers) {
        final xRatio = marker.elapsedMs / maxElapsed;
        final x = chart.left + chart.width * xRatio.clamp(0, 1);
        _drawDashedLine(
          canvas,
          Offset(x, chart.top),
          Offset(x, chart.bottom),
          markerPaint,
        );
        final labelOffset = marker.boundary == ScopeMarkerBoundary.start
            ? Offset(
                (x + 2).clamp(chart.left, chart.right - 44).toDouble(),
                chart.top,
              )
            : Offset(
                (x - 42).clamp(chart.left, chart.right - 44).toDouble(),
                chart.top + 12,
              );
        _paintText(canvas, textPainter, marker.label, labelOffset, labelStyle);
      }
    }

    final linePaint = Paint()
      ..color = color
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
    for (var index = 1; index < values.length; index += 1) {
      final previous = values[index - 1];
      final current = values[index];
      final start = pointFor(previous.point, previous.value);
      final end = pointFor(current.point, current.value);
      if (current.point.startsAfterGap) {
        _drawDashedLine(canvas, start, end, linePaint);
      } else {
        canvas.drawLine(start, end, linePaint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant _TelemetryChartPainter oldDelegate) {
    return oldDelegate.points != points ||
        oldDelegate.color != color ||
        oldDelegate.borderColor != borderColor ||
        oldDelegate.labelColor != labelColor ||
        oldDelegate.minGuide != minGuide ||
        oldDelegate.maxGuide != maxGuide ||
        oldDelegate.markers != markers;
  }
}

void _paintText(
  Canvas canvas,
  TextPainter textPainter,
  String value,
  Offset offset,
  TextStyle style,
) {
  textPainter
    ..text = TextSpan(text: value, style: style)
    ..layout(maxWidth: 72);
  textPainter.paint(canvas, offset);
}

String _formatAxisDuration(int milliseconds) {
  final duration = Duration(milliseconds: milliseconds);
  if (duration.inHours > 0) {
    return '${duration.inHours}:'
        '${duration.inMinutes.remainder(60).toString().padLeft(2, '0')}';
  }
  return '${duration.inMinutes}:'
      '${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}';
}

final class _HistoryWatchMetric extends StatelessWidget {
  const _HistoryWatchMetric({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(label, style: Theme.of(context).textTheme.labelMedium),
        RichText(
          text: TextSpan(
            style: AppTextStyles.scoreNumber(context).copyWith(fontSize: 24),
            children: [TextSpan(text: value)],
          ),
        ),
      ],
    );
  }
}

bool _hasWatchStats(WorkoutHistory history) {
  return history.minHeartRateBpm != null ||
      history.averageHeartRateBpm != null ||
      history.maxHeartRateBpm != null ||
      history.totalDistanceMeters != null ||
      history.totalCaloriesKcal != null;
}

String _formatHistoryDistance(double meters) {
  if (meters >= 1000) {
    return '${(meters / 1000).toStringAsFixed(2)} km';
  }
  return '${meters.round()} m';
}

final class HistoryDetailData {
  const HistoryDetailData({required this.programs});

  factory HistoryDetailData.fromHistory(WorkoutHistory history) {
    final snapshot =
        jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
    final resolved =
        jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String)
            as Map<String, dynamic>;
    final rawResults = history.results.isNotEmpty
        ? history.results.map(_resultFromDomain).toList()
        : (snapshot['results'] as List<dynamic>? ?? const [])
              .cast<Map<String, dynamic>>();
    final rawStepResults = history.stepResults.isNotEmpty
        ? history.stepResults.map(_stepResultFromDomain).toList()
        : (snapshot['stepResults'] as List<dynamic>? ?? const [])
              .cast<Map<String, dynamic>>();
    final programs = <HistoryProgramDetail>[];
    final rawPrograms = resolved['programs'] as List<dynamic>? ?? const [];
    for (
      var programIndex = 0;
      programIndex < rawPrograms.length;
      programIndex++
    ) {
      final rawProgram = rawPrograms[programIndex] as Map<String, dynamic>;
      final programSnapshot =
          jsonDecode(rawProgram['programSnapshotJson'] as String)
              as Map<String, dynamic>;
      final exercises = <HistoryExerciseDetail>[];
      final rawExercises =
          programSnapshot['exercises'] as List<dynamic>? ?? const [];
      for (
        var exerciseIndex = 0;
        exerciseIndex < rawExercises.length;
        exerciseIndex++
      ) {
        final rawExercise = rawExercises[exerciseIndex] as Map<String, dynamic>;
        final exerciseResults = rawResults.where((result) {
          return result['programIndex'] == programIndex &&
              result['exerciseIndex'] == exerciseIndex;
        }).toList();
        final exerciseStepResults = rawStepResults.where((result) {
          return result['programIndex'] == programIndex &&
              result['exerciseIndex'] == exerciseIndex;
        }).toList();
        exercises.add(
          HistoryExerciseDetail(
            name: rawExercise['exerciseNameSnapshot'] as String,
            sets: [
              for (final result in exerciseResults)
                HistorySetDetail.fromSnapshot(
                  rawExercise,
                  result,
                  exerciseStepResults
                      .where(
                        (stepResult) =>
                            stepResult['setIndex'] == result['setIndex'],
                      )
                      .toList(),
                ),
            ],
          ),
        );
      }
      programs.add(
        HistoryProgramDetail(
          name: rawProgram['programNameSnapshot'] as String,
          exercises: exercises,
        ),
      );
    }
    return HistoryDetailData(programs: programs);
  }

  final List<HistoryProgramDetail> programs;
}

final class HistoryProgramDetail {
  const HistoryProgramDetail({required this.name, required this.exercises});

  final String name;
  final List<HistoryExerciseDetail> exercises;
}

final class HistoryExerciseDetail {
  const HistoryExerciseDetail({required this.name, required this.sets});

  final String name;
  final List<HistorySetDetail> sets;
}

final class HistorySetDetail {
  const HistorySetDetail({
    required this.setIndex,
    required this.summary,
    required this.stepPassages,
  });

  factory HistorySetDetail.fromSnapshot(
    Map<String, dynamic> exercise,
    Map<String, dynamic> result,
    List<Map<String, dynamic>> stepResults,
  ) {
    final parts = <String>[];
    if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) {
      parts.add('Temps ${_formatDurationMs(result['actualTimeMs'] as int)}');
    }
    if (exercise['repsEnabled'] == true && result['actualReps'] != null) {
      parts.add('${result['actualReps']} répétitions');
    }
    if (exercise['scoreEnabled'] == true && result['actualScore'] != null) {
      final unit = exercise['scoreUnitSnapshot'] as String?;
      final value = _formatScoreValue(result['actualScore'] as num);
      parts.add(unit == null ? 'Score $value' : 'Score $value $unit');
    }
    if (exercise['scoreEnabled'] == true &&
        _historyScoreInputMode(exercise, result) == ScoreInputMode.stopwatch &&
        result['actualScoreTimeMs'] != null) {
      final value = _formatScoreStopwatch(
        Duration(milliseconds: result['actualScoreTimeMs'] as int),
      );
      final target = _targetScoreTimeMsFromSnapshot(exercise, result);
      if (target == null) {
        parts.add('Temps réalisé : $value');
      } else {
        parts.add(
          'Temps réalisé : $value / objectif '
          '${_formatScoreStopwatchTarget(Duration(milliseconds: target))}',
        );
      }
    }
    return HistorySetDetail(
      setIndex: result['setIndex'] as int,
      summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
      stepPassages: _stepPassagesFromResults(stepResults),
    );
  }

  final int setIndex;
  final String summary;
  final List<HistoryStepPassageDetail> stepPassages;
}

final class HistoryStepPassageDetail {
  const HistoryStepPassageDetail({required this.index, required this.steps});

  final int index;
  final List<HistoryStepResultDetail> steps;
}

final class HistoryStepResultDetail {
  const HistoryStepResultDetail({
    required this.index,
    required this.name,
    required this.status,
    required this.valueLabel,
    this.scoreLabel,
  });

  factory HistoryStepResultDetail.fromResult(Map<String, dynamic> result) {
    final status = _setResultStatusFromSnapshot(result['status']);
    return HistoryStepResultDetail(
      index: result['stepIndex'] as int,
      name: result['stepNameSnapshot'] as String,
      status: status,
      valueLabel: status == SetResultStatus.skipped
          ? null
          : _historyStepValueLabel(result),
      scoreLabel: status == SetResultStatus.skipped
          ? null
          : _historyStepScoreLabel(result),
    );
  }

  final int index;
  final String name;
  final SetResultStatus status;
  final String? valueLabel;
  final String? scoreLabel;
}

final class _HistoryStepResultsSection extends StatelessWidget {
  const _HistoryStepResultsSection({required this.passages});

  final List<HistoryStepPassageDetail> passages;

  @override
  Widget build(BuildContext context) {
    final tokens = courtBlazerTokensOf(context);
    return Padding(
      padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'Étapes réalisées',
            style: Theme.of(context).textTheme.labelLarge,
          ),
          const SizedBox(height: 8),
          for (final passage in passages) ...[
            Text(
              'Passage ${passage.index + 1}',
              style: Theme.of(context).textTheme.labelMedium,
            ),
            const SizedBox(height: 4),
            for (final step in passage.steps)
              Padding(
                padding: const EdgeInsets.only(bottom: 4),
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Expanded(child: Text(_historyStepLine(step))),
                    if (step.status == SetResultStatus.skipped) ...[
                      const SizedBox(width: 8),
                      Chip(
                        visualDensity: VisualDensity.compact,
                        label: const Text('Passée'),
                        side: BorderSide(color: tokens.border),
                      ),
                    ],
                  ],
                ),
              ),
            const SizedBox(height: 8),
          ],
        ],
      ),
    );
  }
}

Map<String, List<WorkoutHistory>> groupHistoriesByPeriod(
  List<WorkoutHistory> histories,
  DateTime now,
) {
  final today = DateTime(now.year, now.month, now.day);
  final weekStart = today.subtract(Duration(days: today.weekday - 1));
  final groups = <String, List<WorkoutHistory>>{
    "Aujourd'hui": [],
    'Cette semaine': [],
    'Plus ancien': [],
  };
  for (final history in histories) {
    final startedDay = DateTime(
      history.startedAt.year,
      history.startedAt.month,
      history.startedAt.day,
    );
    if (startedDay == today) {
      groups["Aujourd'hui"]!.add(history);
    } else if (startedDay.isAfter(weekStart) || startedDay == weekStart) {
      groups['Cette semaine']!.add(history);
    } else {
      groups['Plus ancien']!.add(history);
    }
  }
  groups.removeWhere((key, value) => value.isEmpty);
  return groups;
}

Map<String, dynamic> _resultFromDomain(WorkoutHistorySetResult result) {
  return {
    'programIndex': result.programIndex,
    'exerciseIndex': result.exerciseIndex,
    'setIndex': result.setIndex,
    'actualTimeMs': result.actualTimeMs,
    'actualReps': result.actualReps,
    'actualScore': result.actualScore,
    'actualScoreTimeMs': result.actualScoreTimeMs,
    'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
    'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
  };
}

Map<String, dynamic> _stepResultFromDomain(WorkoutHistoryStepResult result) {
  return {
    'programIndex': result.programIndex,
    'exerciseIndex': result.exerciseIndex,
    'setIndex': result.setIndex,
    'passageIndex': result.passageIndex,
    'stepIndex': result.stepIndex,
    'stepNameSnapshot': result.stepNameSnapshot,
    'stepTypeSnapshot': result.stepTypeSnapshot.name,
    'targetValueSnapshot': result.targetValueSnapshot,
    'hasScoreSnapshot': result.hasScoreSnapshot,
    'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
    'scoreLabelSnapshot': result.scoreLabelSnapshot,
    'scoreUnitSnapshot': result.scoreUnitSnapshot,
    'actualTimeMs': result.actualTimeMs,
    'actualReps': result.actualReps,
    'actualScore': result.actualScore,
    'actualScoreTimeMs': result.actualScoreTimeMs,
    'status': result.status.name,
  };
}

List<HistoryStepPassageDetail> _stepPassagesFromResults(
  List<Map<String, dynamic>> results,
) {
  if (results.isEmpty) return const [];
  final byPassage = <int, List<Map<String, dynamic>>>{};
  for (final result in results) {
    final passageIndex = result['passageIndex'] as int;
    byPassage.putIfAbsent(passageIndex, () => []).add(result);
  }
  final passages = byPassage.entries.toList()
    ..sort((left, right) => left.key.compareTo(right.key));
  return passages
      .map((entry) {
        final steps = entry.value
          ..sort(
            (left, right) =>
                (left['stepIndex'] as int).compareTo(right['stepIndex'] as int),
          );
        return HistoryStepPassageDetail(
          index: entry.key,
          steps: steps.map(HistoryStepResultDetail.fromResult).toList(),
        );
      })
      .toList(growable: false);
}

String _historyStepLine(HistoryStepResultDetail step) {
  final parts = <String>['Étape ${step.index + 1}', step.name];
  if (step.valueLabel != null) {
    parts.add(step.valueLabel!);
  }
  if (step.scoreLabel != null) {
    parts.add(step.scoreLabel!);
  }
  return parts.join(' · ');
}

String? _historyStepValueLabel(Map<String, dynamic> result) {
  final type = result['stepTypeSnapshot'];
  if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) {
    return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}';
  }
  if (type == ExerciseStepType.reps.name && result['actualReps'] != null) {
    return 'Répétitions ${result['actualReps']}';
  }
  return null;
}

String? _historyStepScoreLabel(Map<String, dynamic> result) {
  if (result['hasScoreSnapshot'] != true) return null;
  final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']);
  if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) {
    return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}';
  }
  if (mode == ScoreInputMode.manual && result['actualScore'] != null) {
    final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score';
    final score = _formatScoreValue(result['actualScore'] as num);
    final unit = result['scoreUnitSnapshot'] as String?;
    final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit';
    return '$scoreLabel : $score$suffix';
  }
  return null;
}

SetResultStatus _setResultStatusFromSnapshot(Object? value) {
  return switch (value) {
    'skipped' => SetResultStatus.skipped,
    _ => SetResultStatus.completed,
  };
}

String _historySummary(WorkoutHistory history) {
  final snapshot =
      jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
  final rawResults = history.results.isNotEmpty
      ? history.results.map(_resultFromDomain).toList()
      : (snapshot['results'] as List<dynamic>? ?? const [])
            .cast<Map<String, dynamic>>();
  final scoreCount = rawResults
      .where(
        (result) =>
            result['actualScore'] != null ||
            result['actualScoreTimeMs'] != null,
      )
      .length;
  return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}';
}

String _formatScoreValue(num value) {
  if (value is int || value == value.roundToDouble()) {
    return value.round().toString();
  }
  return value.toString();
}

String _formatDateTime(DateTime value) {
  final day = value.day.toString().padLeft(2, '0');
  final month = value.month.toString().padLeft(2, '0');
  final hour = value.hour.toString().padLeft(2, '0');
  final minute = value.minute.toString().padLeft(2, '0');
  return '$day/$month/${value.year} $hour:$minute';
}

String _formatDurationMs(int milliseconds) {
  final duration = Duration(milliseconds: milliseconds);
  final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
  final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
  final hours = duration.inHours;
  if (hours > 0) {
    return '$hours:$minutes:$seconds';
  }
  return '$minutes:$seconds';
}

String _formatScoreStopwatch(Duration duration) {
  final totalTenths = (duration.inMilliseconds / 100).floor();
  final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
  final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
  final tenths = totalTenths % 10;
  return '$minutes:$seconds.$tenths';
}

String _formatScoreStopwatchTarget(Duration duration) {
  if (duration.inMilliseconds % 1000 == 0) {
    return _formatDurationMs(duration.inMilliseconds);
  }
  return _formatScoreStopwatch(duration);
}

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

int? _targetScoreTimeMsFromSnapshot(
  Map<String, dynamic> exercise,
  Map<String, dynamic> result,
) {
  return result['targetScoreTimeMsSnapshot'] as int? ??
      (exercise['targetScoreTimeMs'] as int?);
}

ScoreInputMode _historyScoreInputMode(
  Map<String, dynamic> exercise,
  Map<String, dynamic> result,
) {
  final resultMode = result['scoreInputModeSnapshot'];
  if (resultMode != null) {
    return _scoreInputModeFromSnapshot(resultMode);
  }
  return _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
}

final class _CenteredMessage extends StatelessWidget {
  const _CenteredMessage({required this.title, this.message});

  final String title;
  final String? message;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              title,
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            if (message != null) ...[
              const SizedBox(height: 8),
              Text(message!, textAlign: TextAlign.center),
            ],
          ],
        ),
      ),
    );
  }
}
