// test/editor_repository_test.dart
//
// Tests for EditorRepository (MF3 diff-write contract + round-trip).
//
// Run via:
//   bash tool/test.sh test/editor_repository_test.dart

import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite_common/sqlite_api.dart';
import 'package:sqflite_common/utils/utils.dart' as sqflite_utils;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

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

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Open an in-memory sqflite database with the ink + notebook_pages schema.
Future<Database> _openTestDb() async {
  sqfliteFfiInit();
  final factory = databaseFactoryFfi;

  // Use a temp-file DB so the test is isolated but still exercises real I/O.
  final dir = await Directory.systemTemp.createTemp('editor_repo_test_');
  final path = p.join(dir.path, 'test.db');

  return factory.openDatabase(
    path,
    options: OpenDatabaseOptions(
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE ink (
            id TEXT PRIMARY KEY,
            host_kind TEXT NOT NULL,
            host_id TEXT NOT NULL,
            stroke_json TEXT NOT NULL,
            ordinal INTEGER NOT NULL,
            updated_at INTEGER NOT NULL
          )
        ''');
        await db.execute(
          'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
        );
        await db.execute('''
          CREATE TABLE notebook_pages (
            id TEXT PRIMARY KEY,
            document_id TEXT NOT NULL,
            ordinal INTEGER NOT NULL,
            source_page_index INTEGER NOT NULL,
            kind TEXT NOT NULL,
            created_at INTEGER NOT NULL
          )
        ''');
      },
    ),
  );
}

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

/// Build [n] distinct strokes.
List<EditorStroke> _strokes(int n) =>
    List.generate(n, (i) => _stroke('stroke-$i'));

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

  group('EditorRepository', () {
    late Database db;
    late EditorRepository repo;

    setUp(() async {
      db = await _openTestDb();
      repo = EditorRepository(db);
    });

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

    // ── Round-trip ─────────────────────────────────────────────────────

    test('round-trip: saveHost then loadDocument returns same strokes', () async {
      const docId = 'doc-rt';
      final hostId = EditorRepository.pageHostId(docId, 0);
      final original = _strokes(5);

      await repo.saveHost('page', hostId, original);

      final loaded = await repo.loadDocument(docId);

      expect(loaded.containsKey(hostId), isTrue);
      final returned = loaded[hostId]!;
      expect(returned.length, equals(original.length));
      for (var i = 0; i < original.length; i++) {
        expect(returned[i].id, equals(original[i].id));
        expect(returned[i].points.length, equals(original[i].points.length));
        expect(returned[i].color, equals(original[i].color));
        expect(returned[i].width, equals(original[i].width));
      }
    });

    // ── 2000-stroke seed + 1-stroke delete ────────────────────────────

    test(
      'seed 2000 strokes, delete 1: second save issues exactly 1 DELETE and 0 INSERTs',
      () async {
        const docId = 'doc-2000';
        final hostId = EditorRepository.pageHostId(docId, 0);
        final all = _strokes(2000);

        // First save: all 2000 strokes inserted (not under test here).
        await repo.saveHost('page', hostId, all);

        // Verify row count is 2000.
        final countBefore = sqflite_utils.firstIntValue(
          await db.rawQuery(
            'SELECT COUNT(*) FROM ink WHERE host_id = ?',
            [hostId],
          ),
        )!;
        expect(countBefore, equals(2000));

        // Record which ids existed before the deletion.
        final idsBefore = (await db.query(
          'ink',
          columns: ['id'],
          where: 'host_id = ?',
          whereArgs: [hostId],
        ))
            .map((r) => r['id'] as String)
            .toSet();

        // Remove stroke at index 500 (arbitrary) — simulate 1 erasure.
        final strokeToRemove = all[500];
        final reduced = List<EditorStroke>.from(all)..removeAt(500);

        // Second save: diff should produce exactly 1 DELETE, 0 INSERTs.
        await repo.saveHost('page', hostId, reduced);

        final countAfter = sqflite_utils.firstIntValue(
          await db.rawQuery(
            'SELECT COUNT(*) FROM ink WHERE host_id = ?',
            [hostId],
          ),
        )!;

        // Row count must drop by exactly 1.
        expect(countAfter, equals(1999));

        // The removed stroke must no longer exist.
        final removedRows = await db.query(
          'ink',
          where: 'id = ?',
          whereArgs: [strokeToRemove.id],
        );
        expect(removedRows, isEmpty);

        // All 1999 surviving ids must be unchanged.
        final idsAfter = (await db.query(
          'ink',
          columns: ['id'],
          where: 'host_id = ?',
          whereArgs: [hostId],
        ))
            .map((r) => r['id'] as String)
            .toSet();

        final expectedSurvivors = Set<String>.from(idsBefore)
          ..remove(strokeToRemove.id);
        expect(idsAfter, equals(expectedSurvivors));

        // No new ids were created (zero INSERTs for the second save).
        final newIds = idsAfter.difference(idsBefore);
        expect(newIds, isEmpty);
      },
    );

    // ── Multiple hosts in same document ───────────────────────────────

    test('loadDocument returns strokes for multiple pages', () async {
      const docId = 'doc-multi';
      final host0 = EditorRepository.pageHostId(docId, 0);
      final host1 = EditorRepository.pageHostId(docId, 1);

      final strokes0 = _strokes(3);
      final strokes1 = _strokes(4).map((s) => _stroke('pg1-${s.id}')).toList();

      await repo.saveHost('page', host0, strokes0);
      await repo.saveHost('page', host1, strokes1);

      final loaded = await repo.loadDocument(docId);
      expect(loaded[host0]!.length, equals(3));
      expect(loaded[host1]!.length, equals(4));
    });

    // ── Idempotency ────────────────────────────────────────────────────

    test('saving the same strokes twice is a no-op (0 DB mutations)', () async {
      const docId = 'doc-idem';
      final hostId = EditorRepository.pageHostId(docId, 0);
      final strokes = _strokes(10);

      await repo.saveHost('page', hostId, strokes);

      final countBefore = sqflite_utils.firstIntValue(
        await db.rawQuery(
          'SELECT COUNT(*) FROM ink WHERE host_id = ?',
          [hostId],
        ),
      )!;

      // Second save with identical strokes: should be a no-op.
      await repo.saveHost('page', hostId, strokes);

      final countAfter = sqflite_utils.firstIntValue(
        await db.rawQuery(
          'SELECT COUNT(*) FROM ink WHERE host_id = ?',
          [hostId],
        ),
      )!;

      expect(countAfter, equals(countBefore));
    });
  });
}
