// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// This test is a use case of flutter/flutter#60796
// the test should be run as:
// flutter drive -t test/using_array.dart --driver test_driver/scrolling_test_e2e_test.dart

import 'package:complex_layout/main.dart' as app;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

/// Generates the [PointerEvent] to simulate a drag operation from
/// `center - totalMove/2` to `center + totalMove/2`.
Iterable<PointerEvent> dragInputEvents(
  final Duration epoch,
  final Offset center, {
  final Offset totalMove = const Offset(0, -400),
  final Duration totalTime = const Duration(milliseconds: 2000),
  final double frequency = 90,
}) sync* {
  final Offset startLocation = center - totalMove / 2;
  // The issue is about 120Hz input on 90Hz refresh rate device.
  // We test 90Hz input on 60Hz device here, which shows similar pattern.
  final int moveEventCount = totalTime.inMicroseconds * frequency ~/ const Duration(seconds: 1).inMicroseconds;
  final Offset movePerEvent = totalMove / moveEventCount.toDouble();
  yield PointerAddedEvent(
    timeStamp: epoch,
    position: startLocation,
  );
  yield PointerDownEvent(
    timeStamp: epoch,
    position: startLocation,
    pointer: 1,
  );
  for (int t = 0; t < moveEventCount + 1; t++) {
    final Offset position = startLocation + movePerEvent * t.toDouble();
    yield PointerMoveEvent(
      timeStamp: epoch + totalTime * t ~/ moveEventCount,
      position: position,
      delta: movePerEvent,
      pointer: 1,
    );
  }
  final Offset position = startLocation + totalMove;
  yield PointerUpEvent(
    timeStamp: epoch + totalTime,
    position: position,
    pointer: 1,
  );
}

enum TestScenario {
  resampleOn90Hz,
  resampleOn59Hz,
  resampleOff90Hz,
  resampleOff59Hz,
}

class ResampleFlagVariant extends TestVariant<TestScenario> {
  ResampleFlagVariant(this.binding);
  final IntegrationTestWidgetsFlutterBinding binding;

  @override
  final Set<TestScenario> values = Set<TestScenario>.from(TestScenario.values);

  late TestScenario currentValue;
  bool get resample {
    switch (currentValue) {
      case TestScenario.resampleOn90Hz:
      case TestScenario.resampleOn59Hz:
        return true;
      case TestScenario.resampleOff90Hz:
      case TestScenario.resampleOff59Hz:
        return false;
    }
  }
  double get frequency {
    switch (currentValue) {
      case TestScenario.resampleOn90Hz:
      case TestScenario.resampleOff90Hz:
        return 90.0;
      case TestScenario.resampleOn59Hz:
      case TestScenario.resampleOff59Hz:
        return 59.0;
    }
  }

  Map<String, dynamic>? result;

  @override
  String describeValue(TestScenario value) {
    switch (value) {
      case TestScenario.resampleOn90Hz:
        return 'resample on with 90Hz input';
      case TestScenario.resampleOn59Hz:
        return 'resample on with 59Hz input';
      case TestScenario.resampleOff90Hz:
        return 'resample off with 90Hz input';
      case TestScenario.resampleOff59Hz:
        return 'resample off with 59Hz input';
    }
  }

  @override
  Future<bool> setUp(TestScenario value) async {
    currentValue = value;
    final bool original = binding.resamplingEnabled;
    binding.resamplingEnabled = resample;
    return original;
  }

  @override
  Future<void> tearDown(TestScenario value, bool memento) async {
    binding.resamplingEnabled = memento;
    binding.reportData![describeValue(value)] = result;
  }
}

Future<void> main() async {
  final WidgetsBinding widgetsBinding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  assert(widgetsBinding is IntegrationTestWidgetsFlutterBinding);
  final IntegrationTestWidgetsFlutterBinding binding = widgetsBinding as IntegrationTestWidgetsFlutterBinding;
  binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.benchmarkLive;
  binding.reportData ??= <String, dynamic>{};
  final ResampleFlagVariant variant = ResampleFlagVariant(binding);
  testWidgets('Smoothness test', (WidgetTester tester) async {
    app.main();
    await tester.pumpAndSettle();
    final Finder scrollerFinder = find.byKey(const ValueKey<String>('complex-scroll'));
    final ListView scroller = tester.widget<ListView>(scrollerFinder);
    final ScrollController? controller = scroller.controller;
    final List<int> frameTimestamp = <int>[];
    final List<double> scrollOffset = <double>[];
    final List<Duration> delays = <Duration>[];
    binding.addPersistentFrameCallback((Duration timeStamp) {
      if (controller?.hasClients ?? false) {
        // This if is necessary because by the end of the test the widget tree
        // is destroyed.
        frameTimestamp.add(timeStamp.inMicroseconds);
        scrollOffset.add(controller!.offset);
      }
    });

    Duration now() => binding.currentSystemFrameTimeStamp;
    Future<void> scroll() async {
      // Extra 50ms to avoid timeouts.
      final Duration startTime = const Duration(milliseconds: 500) + now();
      for (final PointerEvent event in dragInputEvents(
        startTime,
        tester.getCenter(scrollerFinder),
        frequency: variant.frequency,
      )) {
        await tester.binding.delayed(event.timeStamp - now());
        // This now measures how accurate the above delayed is.
        final Duration delay = now() - event.timeStamp;
        if (delays.length < frameTimestamp.length) {
          while (delays.length < frameTimestamp.length - 1) {
            delays.add(Duration.zero);
          }
          delays.add(delay);
        } else if (delays.last < delay) {
          delays.last = delay;
        }
        tester.binding.handlePointerEventForSource(event, source: TestBindingEventSource.test);
      }
    }

    for (int n = 0; n < 5; n++) {
      await scroll();
    }
    variant.result = scrollSummary(scrollOffset, delays, frameTimestamp);
    await tester.pumpAndSettle();
    scrollOffset.clear();
    delays.clear();
    await tester.idle();
  }, semanticsEnabled: false, variant: variant);
}

/// Calculates the smoothness measure from `scrollOffset` and `delays` list.
///
/// Smoothness (`abs_jerk`) is measured by the absolute value of the discrete
/// 2nd derivative of the scroll offset.
///
/// It was experimented that jerk (3rd derivative of the position) is a good
/// measure the smoothness.
/// Here we are using 2nd derivative instead because the input is completely
/// linear and the expected acceleration should be strictly zero.
/// Observed acceleration is jumping from positive to negative within
/// adjacent frames, meaning mathematically the discrete 3-rd derivative
/// (`f[3] - 3*f[2] + 3*f[1] - f[0]`) is not a good approximation of jerk
/// (continuous 3-rd derivative), while discrete 2nd
/// derivative (`f[2] - 2*f[1] + f[0]`) on the other hand is a better measure
/// of how the scrolling deviate away from linear, and given the acceleration
/// should average to zero within two frames, it's also a good approximation
/// for jerk in terms of physics.
/// We use abs rather than square because square (2-norm) amplifies the
/// effect of the data point that's relatively large, but in this metric
/// we prefer smaller data point to have similar effect.
/// This is also why we count the number of data that's larger than a
/// threshold (and the result is tested not sensitive to this threshold),
/// which is effectively a 0-norm.
///
/// Frames that are too slow to build (longer than 40ms) or with input delay
/// longer than 16ms (1/60Hz) is filtered out to separate the janky due to slow
/// response.
///
/// The returned map has keys:
/// `average_abs_jerk`: average for the overall smoothness. The smaller this
/// number the more smooth the scrolling is.
/// `janky_count`: number of frames with `abs_jerk` larger than 0.5. The frames
/// that take longer than the frame budget to build are ignored, so increase of
/// this number itself may not represent a regression.
/// `dropped_frame_count`: number of frames that are built longer than 40ms and
///  are not used for smoothness measurement.
/// `frame_timestamp`: the list of the timestamp for each frame, in the time
/// order.
/// `scroll_offset`: the scroll offset for each frame. Its length is the same as
/// `frame_timestamp`.
/// `input_delay`: the list of maximum delay time of the input simulation during
/// a frame. Its length is the same as `frame_timestamp`
Map<String, dynamic> scrollSummary(
  List<double> scrollOffset,
  List<Duration> delays,
  List<int> frameTimestamp,
) {
  double jankyCount = 0;
  double absJerkAvg = 0;
  int lostFrame = 0;
  for (int i = 1; i < scrollOffset.length-1; i += 1) {
    if (frameTimestamp[i+1] - frameTimestamp[i-1] > 40E3 ||
        (i >= delays.length || delays[i] > const Duration(milliseconds: 16))) {
      // filter data points from slow frame building or input simulation artifact
      lostFrame += 1;
      continue;
    }
    //
    final double absJerk = (scrollOffset[i-1] + scrollOffset[i+1] - 2*scrollOffset[i]).abs();
    absJerkAvg += absJerk;
    if (absJerk > 0.5) {
      jankyCount += 1;
    }
  }
  // expect(lostFrame < 0.1 * frameTimestamp.length, true);
  absJerkAvg /= frameTimestamp.length - lostFrame;

  return <String, dynamic>{
    'janky_count': jankyCount,
    'average_abs_jerk': absJerkAvg,
    'dropped_frame_count': lostFrame,
    'frame_timestamp': List<int>.from(frameTimestamp),
    'scroll_offset': List<double>.from(scrollOffset),
    'input_delay': delays.map<int>((Duration data) => data.inMicroseconds).toList(),
  };
}