import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/infrastructure.dart'
    hide
        ActiveWorkoutTelemetryWindowState,
        WorkoutHistory,
        WorkoutHistorySetResult,
        WorkoutHistoryStepResult,
        WorkoutTelemetryAggregate,
        WorkoutTelemetrySample;
import 'package:watch_bridge_contract/watch_bridge_contract.dart';

void main() {
  test('publishes every projection revision from the source stream', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(_projection(revision: 0));
    final adapter = _adapter(native: native, source: source);
    await adapter.start();
    await Future<void>.delayed(Duration.zero);
    native.published.clear();

    source.emit(_projection(revision: 2));
    source.emit(_projection(revision: 3));
    await Future<void>.delayed(Duration.zero);

    expect(native.published.map((projection) => projection.revision), [2, 3]);
    await adapter.stop();
  });

  test('republishes volatile heartbeats as non urgent keepalives', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(
      _runningProjection(revision: 1),
      incrementsRevisionOnEmit: false,
    );
    final adapter = _adapter(
      native: native,
      source: source,
      heartbeatInterval: const Duration(milliseconds: 10),
    );
    await adapter.start();
    await Future<void>.delayed(Duration.zero);
    native.published.clear();
    native.urgentFlags.clear();
    source.emitCount = 0;

    await Future<void>.delayed(const Duration(milliseconds: 35));

    expect(source.emitCount, greaterThanOrEqualTo(1));
    expect(native.published.length, greaterThanOrEqualTo(1));
    expect(native.urgentFlags, everyElement(false));
    await adapter.stop();
  });

  test('marks only projection revision changes as urgent', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(_runningProjection(revision: 0));
    final adapter = _adapter(native: native, source: source);
    await adapter.start();
    await Future<void>.delayed(Duration.zero);
    native.published.clear();
    native.urgentFlags.clear();

    await adapter.publish(_runningProjection(revision: 2));
    await adapter.publish(_runningProjection(revision: 2));

    expect(native.published.map((projection) => projection.revision), [2]);
    expect(native.urgentFlags, [true]);
    await adapter.stop();
  });

  test(
    'forces an urgent projection resync even when state is unchanged',
    () async {
      final native = _FakeWatchBridgeNativeChannel();
      final source = _FakeProjectionSource(
        _runningProjection(revision: 1),
        incrementsRevisionOnEmit: false,
      );
      final adapter = _adapter(native: native, source: source);
      await adapter.start();
      await Future<void>.delayed(Duration.zero);
      native.published.clear();
      native.urgentFlags.clear();
      source.emitCount = 0;

      native.emitConnection(
        const WatchBridgeConnectionEvent(
          isReachable: true,
          requestsResync: true,
        ),
      );
      await Future<void>.delayed(Duration.zero);

      expect(source.emitCount, 1);
      expect(native.published.map((projection) => projection.revision), [1]);
      expect(native.urgentFlags, [true]);
      await adapter.stop();
    },
  );

  test('dispatches watch command and sends ack back to native layer', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final ingress = _FakeCommandIngress();
    final source = _FakeProjectionSource(_projection(revision: 0));
    final adapter = _adapter(native: native, ingress: ingress, source: source);
    await adapter.start();

    native.emitCommand(_command(WatchCommandType.pauseSession));
    await Future<void>.delayed(Duration.zero);

    expect(ingress.commands.single.type, WatchCommandType.pauseSession);
    expect(native.acks.single.ack, WatchCommandAck.accepted);
    expect(native.acks.single.revisionAtAck, 1);
    await adapter.stop();
  });

  test('deduplicates retry before dispatching to ingress again', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final ingress = _FakeCommandIngress();
    final source = _FakeProjectionSource(_projection(revision: 0));
    final adapter = _adapter(native: native, ingress: ingress, source: source);
    await adapter.start();
    final command = _command(WatchCommandType.skipCurrentSet);

    native.emitCommand(command);
    native.emitCommand(command);
    await Future<void>.delayed(Duration.zero);

    expect(ingress.commands, hasLength(1));
    expect(native.acks.map((ack) => ack.ack), [
      WatchCommandAck.accepted,
      WatchCommandAck.acceptedNoOp,
    ]);
    await adapter.stop();
  });

  test('emits a full resync when a watch node reconnects', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(_projection(revision: 3));
    final adapter = _adapter(native: native, source: source);
    await adapter.start();
    await Future<void>.delayed(Duration.zero);
    native.published.clear();
    source.emitCount = 0;

    native.emitConnection(
      const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
    );
    await Future<void>.delayed(Duration.zero);

    expect(source.emitCount, 1);
    expect(native.published.single.revision, 5);
    await adapter.stop();
  });

  test('processes commands sequentially in receive order', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final ingress = _BlockingCommandIngress();
    final source = _FakeProjectionSource(_projection(revision: 0));
    final adapter = _adapter(native: native, ingress: ingress, source: source);
    await adapter.start();

    native.emitCommand(_command(WatchCommandType.skipCurrentStep, id: 'first'));
    native.emitCommand(_command(WatchCommandType.skipCurrentSet, id: 'second'));
    await Future<void>.delayed(Duration.zero);

    expect(ingress.started, ['first']);
    ingress.completeNext();
    await Future<void>.delayed(Duration.zero);

    expect(ingress.started, ['first', 'second']);
    ingress.completeNext();
    await Future<void>.delayed(Duration.zero);

    expect(native.acks.map((ack) => ack.command.commandId), [
      'first',
      'second',
    ]);
    await adapter.stop();
  });

  test('patches workout history when a sensor summary arrives', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(_projection(revision: 0));
    final historyRepository = _FakeWorkoutHistoryRepository()
      ..histories.add(
        WorkoutHistory(
          metadata: _metadata('history-1'),
          sourceActiveWorkoutSessionId: 'session-1',
          nameSnapshot: 'Seance',
          startedAt: _now.subtract(const Duration(hours: 1)),
          endedAt: _now,
          totalActiveMs: 3600000,
          completed: true,
          historySnapshotJson: '{"programs":[]}',
        ),
      );
    final adapter = _adapter(
      native: native,
      source: source,
      historyUseCases: WorkoutHistoryUseCases(
        repository: historyRepository,
        clock: _FakeClock(_now),
      ),
    );
    await adapter.start();

    native.emitSensorSummary(
      const WatchSensorSummary(
        sessionId: 'session-1',
        sampleCount: 8,
        averageHeartRateBpm: 121.5,
        maxHeartRateBpm: 168,
      ),
    );
    await Future<void>.delayed(Duration.zero);

    expect(historyRepository.histories.single.averageHeartRateBpm, 121.5);
    expect(historyRepository.histories.single.maxHeartRateBpm, 168);
    await adapter.stop();
  });

  test('records live telemetry samples in active sensor state', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(_runningProjection(revision: 1));
    final sensorUseCases = ActiveWorkoutSensorUseCases(clock: _FakeClock(_now));
    final adapter = _adapter(
      native: native,
      source: source,
      sensorUseCases: sensorUseCases,
    );
    await adapter.start();

    native.emitSensorSample(
      WatchSensorSample(
        sampleId: 'sample-1',
        sessionId: 'session-1',
        recordedAtEpochMs: _now.millisecondsSinceEpoch,
        heartRateBpm: 120,
        distanceMeters: 500,
      ),
    );
    native.emitSensorSample(
      WatchSensorSample(
        sampleId: 'sample-2',
        sessionId: 'session-1',
        recordedAtEpochMs: _now
            .add(const Duration(minutes: 30))
            .millisecondsSinceEpoch,
        heartRateBpm: 150,
        distanceMeters: 900,
        caloriesKcal: 120,
      ),
    );
    native.emitSensorSample(
      WatchSensorSample(
        sampleId: 'sample-2',
        sessionId: 'session-1',
        recordedAtEpochMs: _now
            .add(const Duration(minutes: 31))
            .millisecondsSinceEpoch,
        heartRateBpm: 170,
        distanceMeters: 100,
        caloriesKcal: 10,
      ),
    );
    await Future<void>.delayed(Duration.zero);

    final state = sensorUseCases.current('session-1');
    expect(state?.latestHeartRateBpm, 150);
    expect(state?.sampleCount, 2);
    expect(state?.averageHeartRateBpm, 135);
    expect(state?.maxHeartRateBpm, 150);
    expect(state?.latestDistanceMeters, 900);
    expect(state?.latestCaloriesKcal, 120);
    expect(state?.estimatedCaloriesKcal, 187.5);
    await adapter.stop();
    await sensorUseCases.dispose();
  });

  test('persists telemetry samples from the native stream', () async {
    final native = _FakeWatchBridgeNativeChannel();
    final source = _FakeProjectionSource(_runningProjection(revision: 1));
    final telemetryRepository = _FakeWorkoutTelemetryRepository();
    final adapter = _adapter(
      native: native,
      source: source,
      telemetryUseCases: WorkoutTelemetryUseCases(
        repository: telemetryRepository,
        clock: _FakeClock(_now),
        ids: _FakeIds(),
      ),
    );
    await adapter.start();

    native.emitSensorSample(
      WatchSensorSample(
        sampleId: 'sample-1',
        sessionId: 'session-1',
        recordedAtEpochMs: _now.millisecondsSinceEpoch,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        stepIndex: 0,
        heartRateBpm: 120,
        distanceMeters: 500,
        caloriesKcal: 42,
      ),
    );
    await Future<void>.delayed(Duration.zero);
    expect(telemetryRepository.samples, isEmpty);

    native.emitSensorSample(
      WatchSensorSample(
        sampleId: 'sample-2',
        sessionId: 'session-1',
        recordedAtEpochMs: _now
            .add(const Duration(seconds: 20))
            .millisecondsSinceEpoch,
        programIndex: 0,
        exerciseIndex: 0,
        setIndex: 0,
        stepIndex: 0,
        heartRateBpm: 130,
        distanceMeters: 520,
        caloriesKcal: 45,
      ),
    );
    await Future<void>.delayed(Duration.zero);

    expect(
      telemetryRepository.samples.single.id,
      'telemetry:session-1:1784980800000',
    );
    final aggregate = telemetryRepository.aggregates.singleWhere(
      (aggregate) => aggregate.scope == WorkoutTelemetryAggregateScope.session,
    );
    expect(aggregate.sampleCount, 1);
    expect(aggregate.minHeartRateBpm, 120);
    expect(aggregate.totalDistanceMeters, 500);
    expect(aggregate.totalCaloriesKcal, 42);
    await adapter.stop();
  });
}

WatchWearDataLayerAdapter _adapter({
  required _FakeWatchBridgeNativeChannel native,
  WatchCommandIngress? ingress,
  required _FakeProjectionSource source,
  WorkoutHistoryUseCases? historyUseCases,
  ActiveWorkoutSensorUseCases? sensorUseCases,
  WorkoutTelemetryUseCases? telemetryUseCases,
  Duration heartbeatInterval = const Duration(seconds: 5),
}) {
  return WatchWearDataLayerAdapter(
    nativeChannel: native,
    commandIngress: ingress ?? _FakeCommandIngress(),
    projectionSource: source,
    workoutHistoryUseCases: historyUseCases,
    activeWorkoutSensorUseCases: sensorUseCases,
    workoutTelemetryUseCases: telemetryUseCases,
    projectionRefreshInterval: heartbeatInterval,
  );
}

WatchCommandEnvelope _command(
  WatchCommandType type, {
  String id = 'command-1',
}) {
  return WatchCommandEnvelope(
    commandId: id,
    type: type,
    sessionId: 'session-1',
    expectedRevision: 1,
    sentAtEpochMs: _now.millisecondsSinceEpoch,
  );
}

WatchSessionProjection _projection({required int revision}) {
  return WatchSessionProjection(
    deviceSessionId: 'session-1',
    revision: revision,
    projectedAtEpochMs: _now.millisecondsSinceEpoch,
    phase: WatchSessionPhase.ready,
    phoneReachable: true,
    seriesIndex: 1,
    seriesTotal: 2,
    exerciseName: 'Squat',
    primaryAction: WatchPrimaryAction.startCurrentExercise,
  );
}

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

final _now = DateTime.utc(2026, 7, 25, 12);

EntityMetadata _metadata(String id) {
  return EntityMetadata(
    id: id,
    createdAt: _now,
    updatedAt: _now,
    originDeviceId: 'device-1',
  );
}

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

  final DateTime value;

  @override
  DateTime now() => value;
}

final class _FakeIds implements IdGenerator {
  var _next = 0;

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

final class _FakeProjectionSource implements WatchProjectionSource {
  _FakeProjectionSource(this.current, {this.incrementsRevisionOnEmit = true});

  WatchSessionProjection current;
  final bool incrementsRevisionOnEmit;
  var emitCount = 0;
  final _controller = StreamController<WatchSessionProjection>.broadcast();

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

  void emit(WatchSessionProjection projection) {
    current = projection;
    _controller.add(projection);
  }

  @override
  Future<WatchSessionProjection> currentProjection() async => current;

  @override
  Future<WatchSessionProjection> emitCurrentProjection() async {
    emitCount += 1;
    final nextRevision = incrementsRevisionOnEmit
        ? current.revision + 1
        : current.revision;
    current = WatchSessionProjection(
      deviceSessionId: current.deviceSessionId,
      revision: nextRevision,
      projectedAtEpochMs: current.projectedAtEpochMs + 1000,
      expiresAtEpochMs: current.expiresAtEpochMs == 0
          ? 0
          : current.expiresAtEpochMs + 1000,
      phase: current.phase,
      phoneReachable: current.phoneReachable,
      seriesIndex: current.seriesIndex,
      seriesTotal: current.seriesTotal,
      exerciseName: current.exerciseName,
      programIndex: current.programIndex,
      exerciseIndex: current.exerciseIndex,
      setIndex: current.setIndex,
      passageIndex: current.passageIndex,
      passageTotal: current.passageTotal,
      stepIndex: current.stepIndex,
      stepTotal: current.stepTotal,
      stepName: current.stepName,
      stepType: current.stepType,
      stepTargetValue: current.stepTargetValue,
      dominantTimer: current.dominantTimer,
      secondaryTimers: current.secondaryTimers,
      primaryAction: current.primaryAction,
      secondaryActions: current.secondaryActions,
      nextExerciseName: current.nextExerciseName,
      statusLabel: current.statusLabel,
      hasManualScore: current.hasManualScore,
      currentManualScoreValue: current.currentManualScoreValue,
      canDecrementScore: current.canDecrementScore,
      manualScoreTargetValue: current.manualScoreTargetValue,
      manualScoreTargetLabel: current.manualScoreTargetLabel,
      manualScoreRepsTargetValue: current.manualScoreRepsTargetValue,
      manualScoreScope: current.manualScoreScope,
    );
    _controller.add(current);
    return current;
  }
}

final class _FakeCommandIngress implements WatchCommandIngress {
  final commands = <WatchCommandEnvelope>[];

  @override
  Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) async {
    commands.add(command);
    return WatchCommandAck.accepted;
  }
}

final class _BlockingCommandIngress implements WatchCommandIngress {
  final started = <String>[];
  final _pending = <Completer<WatchCommandAck>>[];

  @override
  Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
    started.add(command.commandId);
    final completer = Completer<WatchCommandAck>();
    _pending.add(completer);
    return completer.future;
  }

  void completeNext() {
    _pending.removeAt(0).complete(WatchCommandAck.accepted);
  }
}

final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
  final published = <WatchSessionProjection>[];
  final alerts = <WatchAlertEnvelope>[];
  final acks = <_SentAck>[];
  final _commands = StreamController<WatchCommandEnvelope>.broadcast();
  final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
  final _sensorSamples = StreamController<WatchSensorSample>.broadcast();
  final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
  var capabilityRefreshCount = 0;
  var foregroundStartCount = 0;
  var foregroundStopCount = 0;

  @override
  Stream<WatchCommandEnvelope> get commands => _commands.stream;

  @override
  Stream<WatchSensorSummary> get sensorSummaries => _sensorSummaries.stream;

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

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

  void emitCommand(WatchCommandEnvelope command) {
    _commands.add(command);
  }

  void emitSensorSummary(WatchSensorSummary summary) {
    _sensorSummaries.add(summary);
  }

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

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

  final urgentFlags = <bool>[];

  @override
  Future<void> publishProjection(
    WatchSessionProjection projection, {
    bool urgent = true,
  }) async {
    published.add(projection);
    urgentFlags.add(urgent);
  }

  @override
  Future<void> publishAlert(WatchAlertEnvelope alert) async {
    alerts.add(alert);
  }

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

  @override
  Future<void> sendCommandAck(
    WatchCommandEnvelope command,
    WatchCommandAck ack, {
    int? revisionAtAck,
  }) async {
    acks.add(_SentAck(command, ack, revisionAtAck));
  }

  @override
  Future<void> startForegroundService() async {
    foregroundStartCount += 1;
  }

  @override
  Future<void> stopForegroundService() async {
    foregroundStopCount += 1;
  }
}

final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
  final histories = <WorkoutHistory>[];

  @override
  Future<WorkoutHistory?> findById(String id) async {
    return histories.where((history) => history.metadata.id == id).firstOrNull;
  }

  @override
  Future<List<WorkoutHistory>> listActive() async => histories;

  @override
  Future<void> save(WorkoutHistory history) async {}

  @override
  Future<void> patchHeartRateSummary({
    required String historyId,
    int? minHeartRateBpm,
    required double averageHeartRateBpm,
    required int maxHeartRateBpm,
    double? totalDistanceMeters,
    double? totalCaloriesKcal,
    required DateTime patchedAt,
  }) async {
    final index = histories.indexWhere(
      (history) =>
          history.metadata.id == historyId &&
          history.averageHeartRateBpm == null &&
          history.maxHeartRateBpm == null,
    );
    if (index == -1) {
      return;
    }
    final history = histories[index];
    histories[index] = history.copyWith(
      metadata: history.metadata.touch(patchedAt),
      averageHeartRateBpm: averageHeartRateBpm,
      maxHeartRateBpm: maxHeartRateBpm,
    );
  }

  @override
  Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

  @override
  Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}

  @override
  Future<void> delete(String id, DateTime deletedAt) async {}
}

final class _FakeWorkoutTelemetryRepository
    implements WorkoutTelemetryRepository {
  final samples = <WorkoutTelemetrySample>[];
  final aggregates = <WorkoutTelemetryAggregate>[];
  final windowStates = <String, ActiveWorkoutTelemetryWindowState>{};

  @override
  Future<bool> saveSample(WorkoutTelemetrySample sample) async {
    if (samples.any((existing) => existing.id == sample.id)) {
      return false;
    }
    samples.add(sample);
    return true;
  }

  @override
  Future<ActiveWorkoutTelemetryWindowState?> findWindowState(
    String sessionId,
  ) async {
    return windowStates[sessionId];
  }

  @override
  Future<void> saveWindowState(ActiveWorkoutTelemetryWindowState state) async {
    windowStates[state.sessionId] = state;
  }

  @override
  Future<void> deleteWindowState(String sessionId) async {
    windowStates.remove(sessionId);
  }

  @override
  Future<List<WorkoutTelemetrySample>> listSamples(String sessionId) async {
    return samples
        .where((sample) => sample.sessionId == sessionId)
        .toList(growable: false);
  }

  @override
  Future<List<WorkoutTelemetrySample>> listSamplesForScope({
    required String sessionId,
    required WorkoutTelemetryAggregateScope scope,
    int? programIndex,
    int? exerciseIndex,
    int? setIndex,
    int? passageIndex,
    int? stepIndex,
  }) async {
    return samples
        .where((sample) => sample.sessionId == sessionId)
        .toList(growable: false);
  }

  @override
  Future<void> replaceAggregatesForSession({
    required String sessionId,
    required List<WorkoutTelemetryAggregate> aggregates,
  }) async {
    this.aggregates.removeWhere(
      (aggregate) => aggregate.sessionId == sessionId,
    );
    this.aggregates.addAll(aggregates);
  }

  @override
  Future<List<WorkoutTelemetryAggregate>> listAggregates(
    String sessionId,
  ) async {
    return aggregates
        .where((aggregate) => aggregate.sessionId == sessionId)
        .toList(growable: false);
  }

  @override
  Future<WorkoutTelemetryAggregate?> findAggregate({
    required String sessionId,
    required WorkoutTelemetryAggregateScope scope,
    int? programIndex,
    int? exerciseIndex,
    int? setIndex,
    int? passageIndex,
    int? stepIndex,
  }) async {
    return aggregates
        .where(
          (aggregate) =>
              aggregate.sessionId == sessionId &&
              aggregate.scope == scope &&
              aggregate.programIndex == programIndex &&
              aggregate.exerciseIndex == exerciseIndex &&
              aggregate.setIndex == setIndex &&
              aggregate.passageIndex == passageIndex &&
              aggregate.stepIndex == stepIndex,
        )
        .firstOrNull;
  }
}

final class _SentAck {
  const _SentAck(this.command, this.ack, this.revisionAtAck);

  final WatchCommandEnvelope command;
  final WatchCommandAck ack;
  final int? revisionAtAck;
}
