import 'dart:io' as io;

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_common/object_util.dart';
import 'package:np_common/or_null.dart';
import 'package:np_common/type.dart';
import 'package:np_datetime/np_datetime.dart';
import 'package:np_db/np_db.dart';
import 'package:np_db_sqlite/src/converter.dart';
import 'package:np_db_sqlite/src/database.dart';
import 'package:np_db_sqlite/src/database_extension.dart';
import 'package:np_db_sqlite/src/isolate_util.dart';
import 'package:np_db_sqlite/src/table.dart';
import 'package:np_db_sqlite/src/util.dart';
import 'package:np_platform_util/np_platform_util.dart';

part 'sqlite_api.g.dart';

@npLog
class NpDbSqlite implements NpDb {
  NpDbSqlite();

  @override
  Future<void> initMainIsolate({
    required int? androidSdk,
  }) async {
    initDrift();
    if (getRawPlatform() == NpPlatform.android && androidSdk! < 24) {
      _log.info("[initMainIsolate] Workaround Android 6- bug");
      // see: https://github.com/flutter/flutter/issues/73318 and
      // https://github.com/simolus3/drift/issues/895
      await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
    }

    // use driftIsolate to prevent DB blocking the UI thread
    if (getRawPlatform() == NpPlatform.web) {
      // no isolate support on web
      _db = SqliteDb();
    } else {
      _db = await createDb();
    }
  }

  @override
  Future<void> initBackgroundIsolate({
    required int? androidSdk,
  }) async {
    initDrift();
    // service already runs in an isolate
    _db = SqliteDb();
  }

  @visibleForTesting
  Future<void> initWithDb({
    required SqliteDb db,
  }) async {
    initDrift();
    _db = db;
  }

  @override
  Future<void> dispose() {
    return _db.close();
  }

  @override
  Future<io.File> export(io.Directory dir) => exportSqliteDb(_db, dir);

  @override
  Future<U> compute<T, U>(NpDbComputeCallback<T, U> callback, T args) {
    return _db.isolate(args, (db, message) async {
      final that = NpDbSqlite();
      await that.initWithDb(db: db);
      return callback(that, message);
    });
  }

  @override
  Future<void> addAccounts(List<DbAccount> accounts) {
    return _db.use((db) async {
      await db.insertAccounts(accounts);
    });
  }

  @override
  Future<void> clearAndInitWithAccounts(List<DbAccount> accounts) {
    return _db.use((db) async {
      await db.truncate();
      await db.insertAccounts(accounts);
    });
  }

  @override
  Future<void> deleteAccount(DbAccount account) {
    return _db.use((db) async {
      await db.deleteAccount(account);
    });
  }

  @override
  Future<List<DbAlbum>> getAlbumsByAlbumFileIds({
    required DbAccount account,
    required List<int> fileIds,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryAlbumsByAlbumFileIds(
        account: ByAccount.db(account),
        fileIds: fileIds,
      );
    });
    return sqlObjs.toDbAlbums();
  }

  @override
  Future<void> syncAlbum({
    required DbAccount account,
    required DbFile albumFile,
    required DbAlbum album,
  }) async {
    final sqlAlbum = AlbumConverter.toSql(album);
    await _db.use((db) async {
      await db.syncAlbum(
        account: ByAccount.db(account),
        albumFileEtag: albumFile.etag,
        obj: sqlAlbum,
      );
    });
  }

  @override
  Future<List<DbFaceRecognitionPerson>> getFaceRecognitionPersons({
    required DbAccount account,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryFaceRecognitionPersons(
        account: ByAccount.db(account),
      );
    });
    return sqlObjs.toDbFaceRecognitionPersons();
  }

  @override
  Future<List<DbFaceRecognitionPerson>> searchFaceRecognitionPersonsByName({
    required DbAccount account,
    required String name,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.searchFaceRecognitionPersonByName(
        account: ByAccount.db(account),
        name: name,
      );
    });
    return sqlObjs.toDbFaceRecognitionPersons();
  }

  @override
  Future<DbSyncResult> syncFaceRecognitionPersons({
    required DbAccount account,
    required List<DbFaceRecognitionPerson> persons,
  }) async {
    int sorter(DbFaceRecognitionPerson a, DbFaceRecognitionPerson b) =>
        a.name.compareTo(b.name);
    final to = persons.sorted(sorter);
    return await _db.use((db) async {
      final sqlObjs = await db.queryFaceRecognitionPersons(
        account: ByAccount.db(account),
      );
      final from =
          sqlObjs.map(FaceRecognitionPersonConverter.fromSql).sorted(sorter);
      final diff = getDiffWith(from, to, sorter);
      final inserts = diff.onlyInB;
      _log.info(
          "[replaceFaceRecognitionPersons] New persons: ${inserts.toReadableString()}");
      final deletes = diff.onlyInA;
      _log.info(
          "[replaceFaceRecognitionPersons] Removed persons: ${deletes.toReadableString()}");
      final updates = to.where((t) {
        final f = from.firstWhereOrNull((e) => e.name == t.name);
        return f != null && f != t;
      }).toList();
      _log.info(
          "[replaceFaceRecognitionPersons] Updated persons: ${updates.toReadableString()}");
      if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
        await db.replaceFaceRecognitionPersons(
          account: ByAccount.db(account),
          inserts: inserts,
          deletes: deletes,
          updates: updates,
        );
      }
      return DbSyncResult(
        insert: inserts.length,
        delete: deletes.length,
        update: updates.length,
      );
    });
  }

  @override
  Future<List<DbFile>> getFilesByDirKey({
    required DbAccount account,
    required DbFileKey dir,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryFilesByDirKey(
        account: ByAccount.db(account),
        dir: dir,
      );
    });
    return sqlObjs.toDbFiles();
  }

  @override
  Future<List<DbFile>> getFilesByDirKeyAndLocation({
    required DbAccount account,
    required String dirRelativePath,
    required String? place,
    required String countryCode,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryFilesByLocation(
        account: ByAccount.db(account),
        dirRelativePath: dirRelativePath,
        place: place,
        countryCode: countryCode,
      );
    });
    return sqlObjs.toDbFiles();
  }

  @override
  Future<List<DbFile>> getFilesByFileIds({
    required DbAccount account,
    required List<int> fileIds,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryFilesByFileIds(
        account: ByAccount.db(account),
        fileIds: fileIds,
      );
    });
    return sqlObjs.toDbFiles();
  }

  @override
  Future<List<DbFile>> getFilesByTimeRange({
    required DbAccount account,
    required List<String> dirRoots,
    required TimeRange range,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryFilesByTimeRange(
        account: ByAccount.db(account),
        dirRoots: dirRoots,
        range: range,
      );
    });
    return sqlObjs.toDbFiles();
  }

  @override
  Future<void> updateFileByFileId({
    required DbAccount account,
    required int fileId,
    String? relativePath,
    OrNull<bool>? isFavorite,
    OrNull<bool>? isArchived,
    OrNull<DateTime>? overrideDateTime,
    DateTime? bestDateTime,
    OrNull<DbImageData>? imageData,
    OrNull<DbLocation>? location,
  }) async {
    await _db.use((db) async {
      await db.updateFileByFileId(
        account: ByAccount.db(account),
        fileId: fileId,
        relativePath: relativePath,
        isFavorite: isFavorite,
        isArchived: isArchived,
        overrideDateTime: overrideDateTime,
        bestDateTime: bestDateTime,
        imageData: imageData,
        location: location,
      );
    });
  }

  @override
  Future<void> updateFilesByFileIds({
    required DbAccount account,
    required List<int> fileIds,
    OrNull<bool>? isFavorite,
    OrNull<bool>? isArchived,
  }) async {
    await _db.use((db) async {
      await db.updateFilesByFileIds(
        account: ByAccount.db(account),
        fileIds: fileIds,
        isFavorite: isFavorite,
        isArchived: isArchived,
      );
    });
  }

  @override
  Future<void> syncDirFiles({
    required DbAccount account,
    required DbFileKey dirFile,
    required List<DbFile> files,
  }) async {
    final sqlFiles = await files.toSql();
    await _db.use((db) async {
      await db.syncDirFiles(
        account: ByAccount.db(account),
        dirFile: dirFile,
        objs: sqlFiles,
      );
    });
  }

  @override
  Future<void> syncFile({
    required DbAccount account,
    required DbFile file,
  }) async {
    final sqlFile = FileConverter.toSql(file);
    await _db.use((db) async {
      await db.syncFile(
        account: ByAccount.db(account),
        obj: sqlFile,
      );
    });
  }

  @override
  Future<DbSyncIdResult> syncFavoriteFiles({
    required DbAccount account,
    required List<int> favoriteFileIds,
  }) async {
    int sorter(int a, int b) => a.compareTo(b);
    final to = favoriteFileIds.sorted(sorter);
    return await _db.use((db) async {
      final sqlObjs = await db.queryFileIds(
        account: ByAccount.db(account),
        isFavorite: true,
      );
      final from = sqlObjs.sorted(sorter);
      final diff = getDiffWith(from, to, sorter);
      final inserts = diff.onlyInB;
      _log.info(
          "[syncFavoriteFiles] New favorites: ${inserts.toReadableString()}");
      final deletes = diff.onlyInA;
      _log.info(
          "[syncFavoriteFiles] Removed favorites: ${deletes.toReadableString()}");
      if (inserts.isNotEmpty) {
        await db.updateFilesByFileIds(
          account: ByAccount.db(account),
          fileIds: inserts,
          isFavorite: const OrNull(true),
        );
      }
      if (deletes.isNotEmpty) {
        await db.updateFilesByFileIds(
          account: ByAccount.db(account),
          fileIds: deletes,
          isFavorite: const OrNull(false),
        );
      }
      return DbSyncIdResult(
        insert: inserts,
        delete: deletes,
        update: const [],
      );
    });
  }

  @override
  Future<int> countFilesByMissingMetadata({
    required DbAccount account,
    required List<String> mimes,
  }) async {
    return _db.use((db) async {
      return await db.countFiles(
        account: ByAccount.db(account),
        isMissingMetadata: true,
        mimes: mimes,
      );
    });
  }

  @override
  Future<void> deleteFile({
    required DbAccount account,
    required DbFileKey file,
  }) async {
    await _db.use((db) async {
      return await db.deleteFile(
        account: ByAccount.db(account),
        file: file,
      );
    });
  }

  @override
  Future<Map<int, String>> getDirFileIdToEtagByLikeRelativePath({
    required DbAccount account,
    required String relativePath,
  }) async {
    return await _db.use((db) async {
      return await db.getDirFileIdToEtagByLikeRelativePath(
        account: ByAccount.db(account),
        relativePath: relativePath,
      );
    });
  }

  @override
  Future<void> truncateDir({
    required DbAccount account,
    required DbFileKey dir,
  }) async {
    await _db.use((db) async {
      return await db.truncateDir(
        account: ByAccount.db(account),
        dir: dir,
      );
    });
  }

  @override
  Future<List<DbFileDescriptor>> getFileDescriptors({
    required DbAccount account,
    List<int>? fileIds,
    List<String>? includeRelativeRoots,
    List<String>? includeRelativeDirs,
    List<String>? excludeRelativeRoots,
    List<String>? relativePathKeywords,
    String? location,
    bool? isFavorite,
    bool? isArchived,
    List<String>? mimes,
    TimeRange? timeRange,
    int? offset,
    int? limit,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryFileDescriptors(
        account: ByAccount.db(account),
        fileIds: fileIds,
        includeRelativeRoots: includeRelativeRoots,
        includeRelativeDirs: includeRelativeDirs,
        excludeRelativeRoots: excludeRelativeRoots,
        relativePathKeywords: relativePathKeywords,
        location: location,
        isFavorite: isFavorite,
        isArchived: isArchived,
        mimes: mimes,
        timeRange: timeRange,
        offset: offset,
        limit: limit,
      );
    });
    return sqlObjs.toDbFileDescriptors();
  }

  @override
  Future<DbFilesSummary> getFilesSummary({
    required DbAccount account,
    List<String>? includeRelativeRoots,
    List<String>? excludeRelativeRoots,
    List<String>? mimes,
  }) async {
    final result = await _db.use((db) async {
      return await db.countFileGroupsByDate(
        account: ByAccount.db(account),
        includeRelativeRoots: includeRelativeRoots,
        excludeRelativeRoots: excludeRelativeRoots,
        mimes: mimes,
        isArchived: false,
      );
    });
    return DbFilesSummary(
      items: result.dateCount
          .map((key, value) => MapEntry(key, DbFilesSummaryItem(count: value))),
    );
  }

  @override
  Future<DbFilesMemory> getFilesMemories({
    required DbAccount account,
    required Date at,
    required int radius,
    List<String>? includeRelativeRoots,
    List<String>? excludeRelativeRoots,
    List<String>? mimes,
  }) async {
    final result = await _db.use((db) async {
      return await db.queryFileDescriptorMemories(
        account: ByAccount.db(account),
        at: at,
        radius: radius,
        includeRelativeRoots: includeRelativeRoots,
        excludeRelativeRoots: excludeRelativeRoots,
        mimes: mimes,
      );
    });
    final memories = <int, List<DbFileDescriptor>>{};
    for (final r in result.map(FileDescriptorConverter.fromSql)) {
      (memories[r.bestDateTime.year] ??= <DbFileDescriptor>[]).add(r);
    }
    return DbFilesMemory(memories: memories);
  }

  @override
  Future<DbLocationGroupResult> groupLocations({
    required DbAccount account,
    List<String>? includeRelativeRoots,
    List<String>? excludeRelativeRoots,
  }) async {
    List<ImageLocationGroup>? nameResult, admin1Result, admin2Result, ccResult;
    await _db.use((db) async {
      try {
        nameResult = await db.groupImageLocationsByName(
          account: ByAccount.db(account),
          includeRelativeRoots: includeRelativeRoots,
          excludeRelativeRoots: excludeRelativeRoots,
        );
      } catch (e, stackTrace) {
        _log.shout("[groupLocation] Failed while groupImageLocationsByName", e,
            stackTrace);
      }
      try {
        admin1Result = await db.groupImageLocationsByAdmin1(
          account: ByAccount.db(account),
          includeRelativeRoots: includeRelativeRoots,
          excludeRelativeRoots: excludeRelativeRoots,
        );
      } catch (e, stackTrace) {
        _log.shout("[groupLocation] Failed while groupImageLocationsByAdmin1",
            e, stackTrace);
      }
      try {
        admin2Result = await db.groupImageLocationsByAdmin2(
          account: ByAccount.db(account),
          includeRelativeRoots: includeRelativeRoots,
          excludeRelativeRoots: excludeRelativeRoots,
        );
      } catch (e, stackTrace) {
        _log.shout("[groupLocation] Failed while groupImageLocationsByAdmin2",
            e, stackTrace);
      }
      try {
        ccResult = await db.groupImageLocationsByCountryCode(
          account: ByAccount.db(account),
          includeRelativeRoots: includeRelativeRoots,
          excludeRelativeRoots: excludeRelativeRoots,
        );
      } catch (e, stackTrace) {
        _log.shout(
            "[groupLocation] Failed while groupImageLocationsByCountryCode",
            e,
            stackTrace);
      }
    });
    return DbLocationGroupResult(
      name: nameResult?.toDbLocationGroups() ?? [],
      admin1: admin1Result?.toDbLocationGroups() ?? [],
      admin2: admin2Result?.toDbLocationGroups() ?? [],
      countryCode: ccResult?.toDbLocationGroups() ?? [],
    );
  }

  @override
  Future<List<DbNcAlbum>> getNcAlbums({
    required DbAccount account,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryNcAlbums(
        account: ByAccount.db(account),
      );
    });
    return sqlObjs.toDbNcAlbums();
  }

  @override
  Future<void> addNcAlbum({
    required DbAccount account,
    required DbNcAlbum album,
  }) async {
    await _db.use((db) async {
      await db.insertNcAlbum(account: account, album: album);
    });
  }

  @override
  Future<void> deleteNcAlbum({
    required DbAccount account,
    required DbNcAlbum album,
  }) async {
    await _db.use((db) async {
      await db.deleteNcAlbum(account: account, album: album);
    });
  }

  @override
  Future<DbSyncResult> syncNcAlbums({
    required DbAccount account,
    required List<DbNcAlbum> albums,
  }) async {
    int sorter(DbNcAlbum a, DbNcAlbum b) =>
        a.relativePath.compareTo(b.relativePath);
    final to = albums.sorted(sorter);
    return await _db.use((db) async {
      final sqlObjs = await db.queryNcAlbums(
        account: ByAccount.db(account),
      );
      final from = sqlObjs.map(NcAlbumConverter.fromSql).sorted(sorter);
      final diff = getDiffWith(from, to, sorter);
      final inserts = diff.onlyInB;
      _log.info("[syncNcAlbums] New nc albums: ${inserts.toReadableString()}");
      final deletes = diff.onlyInA;
      _log.info(
          "[syncNcAlbums] Removed nc albums: ${deletes.toReadableString()}");
      final updates = to.where((t) {
        final f =
            from.firstWhereOrNull((e) => e.relativePath == t.relativePath);
        return f != null && f != t;
      }).toList();
      _log.info(
          "[syncNcAlbums] Updated nc albums: ${updates.toReadableString()}");
      if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
        await db.replaceNcAlbums(
          account: ByAccount.db(account),
          inserts: inserts,
          deletes: deletes,
          updates: updates,
        );
      }
      return DbSyncResult(
        insert: inserts.length,
        delete: deletes.length,
        update: updates.length,
      );
    });
  }

  @override
  Future<List<DbNcAlbumItem>> getNcAlbumItemsByParent({
    required DbAccount account,
    required DbNcAlbum parent,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryNcAlbumItemsByParentRelativePath(
        account: ByAccount.db(account),
        parentRelativePath: parent.relativePath,
      );
    });
    return sqlObjs.toDbNcAlbumItems();
  }

  @override
  Future<DbSyncResult> syncNcAlbumItems({
    required DbAccount account,
    required DbNcAlbum album,
    required List<DbNcAlbumItem> items,
  }) async {
    int sorter(DbNcAlbumItem a, DbNcAlbumItem b) =>
        a.fileId.compareTo(b.fileId);
    final to = items.sorted(sorter);
    return await _db.use((db) async {
      final sqlObjs = await db.queryNcAlbumItemsByParentRelativePath(
        account: ByAccount.db(account),
        parentRelativePath: album.relativePath,
      );
      final int parentRowId;
      if (sqlObjs.isNotEmpty) {
        parentRowId = sqlObjs.first.parent;
      } else {
        final parent = await db.queryNcAlbumByRelativePath(
          account: ByAccount.db(account),
          relativePath: album.relativePath,
        );
        parentRowId = parent!.rowId;
      }

      final from = sqlObjs.map(NcAlbumItemConverter.fromSql).sorted(sorter);
      final diff = getDiffWith(from, to, sorter);
      final inserts = diff.onlyInB;
      _log.info(
          "[syncNcAlbumItems] New nc album items: ${inserts.toReadableString()}");
      final deletes = diff.onlyInA;
      _log.info(
          "[syncNcAlbumItems] Removed nc album items: ${deletes.toReadableString()}");
      final updates = to.where((t) {
        final f = from.firstWhereOrNull((e) => e.fileId == t.fileId);
        return f != null && f != t;
      }).toList();
      _log.info(
          "[syncNcAlbumItems] Updated nc album items: ${updates.toReadableString()}");
      if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
        await db.replaceNcAlbumItems(
          parentRowId: parentRowId,
          inserts: inserts,
          deletes: deletes,
          updates: updates,
        );
      }
      return DbSyncResult(
        insert: inserts.length,
        delete: deletes.length,
        update: updates.length,
      );
    });
  }

  @override
  Future<List<DbRecognizeFace>> getRecognizeFaces({
    required DbAccount account,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryRecognizeFaces(
        account: ByAccount.db(account),
      );
    });
    return sqlObjs.toDbRecognizeFaces();
  }

  @override
  Future<List<DbRecognizeFaceItem>> getRecognizeFaceItemsByFaceLabel({
    required DbAccount account,
    required String label,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryRecognizeFaceItemsByFaceLabel(
        account: ByAccount.db(account),
        label: label,
      );
    });
    return sqlObjs.toDbRecognizeFaceItems();
  }

  @override
  Future<Map<String, List<DbRecognizeFaceItem>>>
      getRecognizeFaceItemsByFaceLabels({
    required DbAccount account,
    required List<String> labels,
    ErrorWithValueHandler<String>? onError,
  }) async {
    final results = <String, List<RecognizeFaceItem>>{};
    await _db.use((db) async {
      for (final l in labels) {
        try {
          results[l] = await db.queryRecognizeFaceItemsByFaceLabel(
            account: ByAccount.db(account),
            label: l,
          );
        } catch (e, stackTrace) {
          onError?.call(l, e, stackTrace);
        }
      }
    });
    return results.asyncMap((key, value) =>
        value.toDbRecognizeFaceItems().then((v) => MapEntry(key, v)));
  }

  @override
  Future<Map<String, DbRecognizeFaceItem>>
      getLatestRecognizeFaceItemsByFaceLabels({
    required DbAccount account,
    required List<String> labels,
    ErrorWithValueHandler<String>? onError,
  }) async {
    final results = <String, List<RecognizeFaceItem>>{};
    await _db.use((db) async {
      for (final l in labels) {
        try {
          results[l] = await db.queryRecognizeFaceItemsByFaceLabel(
            account: ByAccount.db(account),
            label: l,
            orderBy: [RecognizeFaceItemSort.fileIdDesc],
            limit: 1,
          );
        } catch (e, stackTrace) {
          onError?.call(l, e, stackTrace);
        }
      }
    });
    return results.asyncMap((key, value) =>
        value.toDbRecognizeFaceItems().then((v) => MapEntry(key, v.first)));
  }

  @override
  Future<bool> syncRecognizeFacesAndItems({
    required DbAccount account,
    required Map<DbRecognizeFace, List<DbRecognizeFaceItem>> data,
  }) async {
    int sorter(DbRecognizeFace a, DbRecognizeFace b) =>
        a.label.compareTo(b.label);
    int itemSorter(DbRecognizeFaceItem a, DbRecognizeFaceItem b) =>
        a.fileId.compareTo(b.fileId);
    final faces = data.keys;
    final to = faces.sorted(sorter);
    final toItems =
        data.map((key, value) => MapEntry(key, value.sorted(itemSorter)));
    return await _db.use((db) async {
      var result = false;
      final sqlAccount = await db.accountOf(ByAccount.db(account));
      final sqlObjs = await db.queryRecognizeFaces(
        account: ByAccount.sql(sqlAccount),
      );
      final from = sqlObjs.map(RecognizeFaceConverter.fromSql).sorted(sorter);
      final diff = getDiffWith(from, to, sorter);
      final inserts = diff.onlyInB;
      _log.info(
          "[syncRecognizeFacesAndItems] New faces: ${inserts.toReadableString()}");
      final deletes = diff.onlyInA;
      _log.info(
          "[syncRecognizeFacesAndItems] Removed faces: ${deletes.toReadableString()}");
      final updates = to.where((t) {
        final f = from.firstWhereOrNull((e) => e.label == t.label);
        return f != null && f != t;
      }).toList();
      _log.info(
          "[syncRecognizeFacesAndItems] Updated faces: ${updates.toReadableString()}");
      if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
        await db.replaceRecognizeFaces(
          account: ByAccount.sql(sqlAccount),
          inserts: inserts,
          deletes: deletes,
          updates: updates,
        );
        result = true;
      }
      sqlObjs.addAll(await db.queryRecognizeFaces(
        account: ByAccount.sql(sqlAccount),
        labels: inserts.map((e) => e.label).toList(),
      ));

      for (final d in data.entries) {
        try {
          result |= await _replaceRecognizeFaceItems(
            db,
            sqlAccount: sqlAccount,
            face: sqlObjs.firstWhere((e) => e.label == d.key.label),
            items: toItems[d.key]!,
            sorter: itemSorter,
          );
        } catch (e, stackTrace) {
          _log.shout(
            "[syncRecognizeFacesAndItems] Failed to replace items for face: ${d.key}",
            e,
            stackTrace,
          );
        }
      }
      return result;
    });
  }

  @override
  Future<List<DbTag>> getTags({
    required DbAccount account,
  }) async {
    final sqlObjs = await _db.use((db) async {
      return await db.queryTags(
        account: ByAccount.db(account),
      );
    });
    return sqlObjs.toDbTags();
  }

  @override
  Future<DbTag?> getTagByDisplayName({
    required DbAccount account,
    required String displayName,
  }) async {
    final sqlObj = await _db.use((db) async {
      return await db.queryTagByDisplayName(
        account: ByAccount.db(account),
        displayName: displayName,
      );
    });
    return sqlObj?.let(TagConverter.fromSql);
  }

  @override
  Future<DbSyncIdResult> syncTags({
    required DbAccount account,
    required List<DbTag> tags,
  }) async {
    int sorter(DbTag a, DbTag b) => a.id.compareTo(b.id);
    final to = tags.sorted(sorter);
    return await _db.use((db) async {
      final sqlObjs = await db.queryTags(
        account: ByAccount.db(account),
      );
      final from = sqlObjs.map(TagConverter.fromSql).sorted(sorter);
      final diff = getDiffWith(from, to, sorter);
      final inserts = diff.onlyInB;
      _log.info("[syncTags] New tags: ${inserts.toReadableString()}");
      final deletes = diff.onlyInA;
      _log.info("[syncTags] Removed tags: ${deletes.toReadableString()}");
      final updates = to.where((t) {
        final f = from.firstWhereOrNull((e) => e.id == t.id);
        return f != null && f != t;
      }).toList();
      _log.info("[syncTags] Updated tags: ${updates.toReadableString()}");
      if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
        await db.replaceTags(
          account: ByAccount.db(account),
          inserts: inserts,
          deletes: deletes,
          updates: updates,
        );
      }
      return DbSyncIdResult(
        insert: inserts.map((e) => e.id).toList(),
        delete: deletes.map((e) => e.id).toList(),
        update: updates.map((e) => e.id).toList(),
      );
    });
  }

  @override
  Future<void> migrateV55(
      void Function(int current, int count)? onProgress) async {
    await _db.use((db) async {
      await db.migrateV55(onProgress);
    });
  }

  @override
  Future<void> sqlVacuum() async {
    await _db.useNoTransaction((db) async {
      await db.customStatement("VACUUM;");
    });
  }

  Future<bool> _replaceRecognizeFaceItems(
    SqliteDb db, {
    required Account sqlAccount,
    required RecognizeFace face,
    required List<DbRecognizeFaceItem> items,
    required int Function(DbRecognizeFaceItem, DbRecognizeFaceItem) sorter,
  }) async {
    final to = items;
    final sqlObjs = await db.queryRecognizeFaceItemsByFaceLabel(
      account: ByAccount.sql(sqlAccount),
      label: face.label,
    );
    final from = sqlObjs.map(RecognizeFaceItemConverter.fromSql).sorted(sorter);
    final diff = getDiffWith(from, to, sorter);
    final inserts = diff.onlyInB;
    _log.info(
        "[_replaceRecognizeFaceItems] New faces: ${inserts.toReadableString()}");
    final deletes = diff.onlyInA;
    _log.info(
        "[_replaceRecognizeFaceItems] Removed faces: ${deletes.toReadableString()}");
    final updates = to.where((t) {
      final f = from.firstWhereOrNull((e) => e.fileId == t.fileId);
      return f != null && f != t;
    }).toList();
    _log.info(
        "[_replaceRecognizeFaceItems] Updated faces: ${updates.toReadableString()}");
    if (inserts.isNotEmpty || deletes.isNotEmpty || updates.isNotEmpty) {
      await db.replaceRecognizeFaceItems(
        face: face,
        inserts: inserts,
        deletes: deletes,
        updates: updates,
      );
      return true;
    }
    return false;
  }

  @Deprecated("For compatibility only")
  SqliteDb get compatDb => _db;

  late final SqliteDb _db;
}