// integration_test/perf_scroll_bench.dart
//
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
//
//   MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
//             (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
//   MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
//             strokes/page painted into pageOverlaysBuilder; median frame BUILD
//             time ≤ 16.6ms.
//
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
// are populated before sampling.
//
// RUN (profile mode, on the Windows tablet or a desktop with a display):
//   flutter test --profile integration_test/perf_scroll_bench.dart
// or via the driver for on-device profiling:
//   flutter drive --profile \
//       --driver=test_driver/integration_test.dart \
//       --target=integration_test/perf_scroll_bench.dart
//
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
// always read the numbers from a PROFILE run on the target device. On a
// headless Linux box pdfium may fail to render; if so the bench prints a clear
// skip and must be run on the tablet (see coordinate_assertion_test header).

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';

import 'package:badnote/editor/pdf/spike_editor_pane.dart';

const String _kPdfPath = 'test/assets/large_300p.pdf';
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';

/// Frames to sample after warm-up.
const int _kSampleFrames = 120;

/// Frames to discard before sampling (cache warm-up, §7.1).
const int _kWarmupFrames = 30;

void main() {
  final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  // Report raw frame timings to the device lab / driver too.
  binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
  pdfrxFlutterInitialize();

  testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
    final pdf = File(_kPdfPath);
    if (!pdf.existsSync()) {
      stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
      return;
    }

    // ---- MUST #4: pdfrx alone ----
    final r4 = await _runScrollPass(
      tester,
      label: 'MUST #4 — pdfrx alone (no ink overlay)',
      inkLoad: false,
    );

    // ---- MUST #5: WITH dense ink overlay ----
    final r5 = await _runScrollPass(
      tester,
      label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
      inkLoad: true,
    );

    if (r4 == null || r5 == null) {
      stdout.writeln(
        '\n=== PERF BENCH SKIPPED ===\n'
        'pdfrx did not become ready (headless pdfium limitation). Run on the '
        'Windows tablet in profile mode:\n'
        '  flutter drive --profile '
        '--driver=test_driver/integration_test.dart '
        '--target=integration_test/perf_scroll_bench.dart\n',
      );
      return;
    }

    _printReport('MUST #4', r4, buildOnlyGate: false);
    _printReport('MUST #5', r5, buildOnlyGate: true);
  });
}

class _Stats {
  _Stats(this.label, this.build, this.raster, this.total);
  final String label;
  final _Series build;
  final _Series raster;
  final _Series total;
}

class _Series {
  _Series(List<double> values)
      : median = _pct(values, 50),
        p95 = _pct(values, 95),
        worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
        jankFrames = values.where((v) => v > 32.0).length,
        n = values.length;
  final double median;
  final double p95;
  final double worst;
  final int jankFrames;
  final int n;

  static double _pct(List<double> v, int p) {
    if (v.isEmpty) return 0;
    final s = List<double>.from(v)..sort();
    final i = ((p / 100.0) * (s.length - 1)).round();
    return s[i.clamp(0, s.length - 1)];
  }
}

/// Pumps the spike pane, warms up, then drives a sustained fling while
/// collecting FrameTiming. Returns null if pdfrx never became ready.
Future<_Stats?> _runScrollPass(
  WidgetTester tester, {
  required String label,
  required bool inkLoad,
}) async {
  final controller = PdfViewerController();
  final paneKey = GlobalKey<SpikeEditorPaneState>();

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: SpikeEditorPane(
          key: paneKey,
          pdfPath: _kPdfPath,
          controller: controller,
          denseStrokesAsset: _kDenseStrokesAsset,
        ),
      ),
    ),
  );

  // Wait for the document to lay out.
  final deadline = DateTime.now().add(const Duration(seconds: 20));
  while (DateTime.now().isBefore(deadline)) {
    await tester.pump(const Duration(milliseconds: 100));
    if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
  }
  if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
    return null;
  }

  if (inkLoad) {
    await paneKey.currentState!.setInkLoad(true);
    await tester.pumpAndSettle();
  }

  // Collect frame timings.
  final build = <double>[];
  final raster = <double>[];
  final total = <double>[];
  var seen = 0;
  void onTimings(List<FrameTiming> timings) {
    for (final t in timings) {
      seen++;
      if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
      if (build.length >= _kSampleFrames) continue;
      build.add(t.buildDuration.inMicroseconds / 1000.0);
      raster.add(t.rasterDuration.inMicroseconds / 1000.0);
      total.add(t.totalSpan.inMicroseconds / 1000.0);
    }
  }

  SchedulerBinding.instance.addTimingsCallback(onTimings);
  try {
    // Sustained fling: repeated downward flings across the viewport center to
    // keep the document scrolling continuously while we gather ≥150 frames.
    final center = tester.getCenter(find.byType(SpikeEditorPane));
    var safety = 0;
    while (build.length < _kSampleFrames && safety < 400) {
      await tester.fling(
        find.byType(SpikeEditorPane),
        const Offset(0, -600),
        2000,
        warnIfMissed: false,
      );
      // Pump several frames to let the fling settle and emit timings.
      for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
        await tester.pump(const Duration(milliseconds: 16));
      }
      // Nudge back up occasionally so we don't run off the end of 300 pages.
      if (safety % 8 == 7) {
        await tester.fling(find.byType(SpikeEditorPane),
            const Offset(0, 1200), 2000, warnIfMissed: false);
        await tester.pump(const Duration(milliseconds: 16));
      }
      safety++;
      // Keep `center` referenced (avoids unused warning) and re-target if needed.
      if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
    }
  } finally {
    SchedulerBinding.instance.removeTimingsCallback(onTimings);
  }

  return _Stats(
    label,
    _Series(build),
    _Series(raster),
    _Series(total),
  );
}

void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
  final buf = StringBuffer();
  buf.writeln('\n========================================================');
  buf.writeln('$tag  —  ${s.label}');
  buf.writeln('Protocol (§7.1): profile mode, warm cache, '
      'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
  buf.writeln('--------------------------------------------------------');
  buf.writeln('phase    median     p95     worst   jank(>32ms)');
  buf.writeln('build  ${_row(s.build)}');
  buf.writeln('raster ${_row(s.raster)}');
  buf.writeln('total  ${_row(s.total)}');
  buf.writeln('--------------------------------------------------------');
  if (buildOnlyGate) {
    final pass = s.build.median <= 16.6;
    buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
        '≤ 16.6ms  -> ${pass ? "PASS" : "FAIL"}');
  } else {
    final passMed = s.total.median <= 16.6;
    final passP95 = s.total.p95 <= 22.0;
    buf.writeln('GATE (MUST #4): build+raster median '
        '${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
        '${passMed ? "PASS" : "FAIL"}; '
        'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
        '${passP95 ? "PASS" : "FAIL"}');
  }
  buf.writeln('========================================================\n');
  stdout.write(buf.toString());
}

String _row(_Series s) =>
    '${s.median.toStringAsFixed(2).padLeft(7)}ms '
    '${s.p95.toStringAsFixed(2).padLeft(6)}ms '
    '${s.worst.toStringAsFixed(2).padLeft(6)}ms '
    '${s.jankFrames.toString().padLeft(6)}';
