// Tests for SaveScheduler (P0 step 8): debounce coalescing, synchronous
// snapshot capture, per-host independence, flush, and dispose. The scheduler's
// value is its batching/timing logic, so we drive it with a recording repo that
// records saveHost calls instead of touching the DB.
//
// Run via:
//   bash tool/test.sh test/save_scheduler_test.dart

import 'package:flutter_test/flutter_test.dart';
// Database + inMemoryDatabasePath are re-exported by sqflite_common_ffi.
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

import 'package:badnote/editor/engine/stroke_model.dart';
import 'package:badnote/editor/persistence/editor_repository.dart';
import 'package:badnote/editor/persistence/save_scheduler.dart';

class _RecordingRepo extends EditorRepository {
  _RecordingRepo(super.db);

  final List<({String kind, String host, List<EditorStroke> strokes})> calls =
      [];

  @override
  Future<void> saveHost(
    String hostKind,
    String hostId,
    List<EditorStroke> strokes,
  ) async {
    calls.add((kind: hostKind, host: hostId, strokes: strokes));
  }
}

EditorStroke _stroke(String id) => EditorStroke.create(
      id: id,
      points: const [
        EditorPoint(x: 0.1, y: 0.2),
        EditorPoint(x: 0.3, y: 0.4),
      ],
    );

void main() {
  late Database db;
  late _RecordingRepo repo;

  setUpAll(() {
    sqfliteFfiInit();
    databaseFactory = databaseFactoryFfi;
  });

  setUp(() async {
    db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
    repo = _RecordingRepo(db);
  });

  tearDown(() async {
    await db.close();
  });

  test('flush writes pending immediately without waiting for the debounce',
      () async {
    final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
    scheduler.schedule('page', 'h1', [_stroke('a')]);
    expect(repo.calls, isEmpty, reason: 'debounce not elapsed yet');

    await scheduler.flush();

    expect(repo.calls, hasLength(1));
    expect(repo.calls.single.host, 'h1');
    expect(repo.calls.single.strokes.single.id, 'a');
    scheduler.dispose();
  });

  test('rapid successive schedules coalesce into ONE write with the latest snapshot',
      () async {
    final scheduler =
        SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
    scheduler.schedule('page', 'h1', [_stroke('v1')]);
    scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2')]);
    scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2'), _stroke('v3')]);

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

    expect(repo.calls, hasLength(1), reason: '3 schedules → 1 debounced write');
    expect(repo.calls.single.strokes.map((s) => s.id),
        ['v1', 'v2', 'v3']);
    scheduler.dispose();
  });

  test('captured snapshot is isolated from later mutation of the source list',
      () async {
    final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
    final source = [_stroke('a')];
    // Caller convention: pass a defensive copy.
    scheduler.schedule('page', 'h1', List.of(source));
    // Mutating the source after scheduling must not affect the write.
    source.add(_stroke('b'));

    await scheduler.flush();

    expect(repo.calls.single.strokes.map((s) => s.id), ['a']);
    scheduler.dispose();
  });

  test('distinct hosts are scheduled and flushed independently', () async {
    final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
    scheduler.schedule('page', 'h1', [_stroke('a')]);
    scheduler.schedule('page', 'h2', [_stroke('b')]);

    await scheduler.flush();

    expect(repo.calls, hasLength(2));
    expect(repo.calls.map((c) => c.host).toSet(), {'h1', 'h2'});
    scheduler.dispose();
  });

  test('dispose cancels a pending write (nothing is persisted)', () async {
    final scheduler =
        SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
    scheduler.schedule('page', 'h1', [_stroke('a')]);
    scheduler.dispose();

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

    expect(repo.calls, isEmpty);
  });

  test('schedule after dispose is a no-op', () async {
    final scheduler =
        SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
    scheduler.dispose();
    scheduler.schedule('page', 'h1', [_stroke('a')]);

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

    expect(repo.calls, isEmpty);
  });
}
