import 'dart:async';
import 'dart:typed_data';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart' show PhotoManager;

/// Finds duplicates originating from missing EXIF information
class BackupVerificationService {
  final Isar _db;

  BackupVerificationService(this._db);

  /// Returns at most [limit] assets that were backed up without exif
  Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
    final owner = Store.get(StoreKey.currentUser).isarId;
    final List<Asset> onlyLocal = await _db.assets
        .where()
        .remoteIdIsNull()
        .filter()
        .ownerIdEqualTo(owner)
        .localIdIsNotNull()
        .findAll();
    final List<Asset> remoteMatches = await _getMatches(
      _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
      owner,
      onlyLocal,
      limit,
    );
    final List<Asset> localMatches = await _getMatches(
      _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
      owner,
      remoteMatches,
      limit,
    );

    final List<Asset> deleteCandidates = [], originals = [];

    await diffSortedLists(
      remoteMatches,
      localMatches,
      compare: (a, b) => a.fileName.compareTo(b.fileName),
      both: (a, b) async {
        a.exifInfo = await _db.exifInfos.get(a.id);
        deleteCandidates.add(a);
        originals.add(b);
        return false;
      },
      onlyFirst: (a) {},
      onlySecond: (b) {},
    );
    final isolateToken = ServicesBinding.rootIsolateToken!;
    final List<Asset> toDelete;
    if (deleteCandidates.length > 10) {
      // performs 2 checks in parallel for a nice speedup
      final half = deleteCandidates.length ~/ 2;
      final lower = compute(
        _computeSaveToDelete,
        (
          deleteCandidates: deleteCandidates.slice(0, half),
          originals: originals.slice(0, half),
          auth: Store.get(StoreKey.accessToken),
          endpoint: Store.get(StoreKey.serverEndpoint),
          rootIsolateToken: isolateToken,
        ),
      );
      final upper = compute(
        _computeSaveToDelete,
        (
          deleteCandidates: deleteCandidates.slice(half),
          originals: originals.slice(half),
          auth: Store.get(StoreKey.accessToken),
          endpoint: Store.get(StoreKey.serverEndpoint),
          rootIsolateToken: isolateToken,
        ),
      );
      toDelete = await lower + await upper;
    } else {
      toDelete = await compute(
        _computeSaveToDelete,
        (
          deleteCandidates: deleteCandidates,
          originals: originals,
          auth: Store.get(StoreKey.accessToken),
          endpoint: Store.get(StoreKey.serverEndpoint),
          rootIsolateToken: isolateToken,
        ),
      );
    }
    return toDelete;
  }

  static Future<List<Asset>> _computeSaveToDelete(
    ({
      List<Asset> deleteCandidates,
      List<Asset> originals,
      String auth,
      String endpoint,
      RootIsolateToken rootIsolateToken,
    }) tuple,
  ) async {
    assert(tuple.deleteCandidates.length == tuple.originals.length);
    final List<Asset> result = [];
    BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
    await PhotoManager.setIgnorePermissionCheck(true);
    final ApiService apiService = ApiService();
    apiService.setEndpoint(tuple.endpoint);
    apiService.setAccessToken(tuple.auth);
    for (int i = 0; i < tuple.deleteCandidates.length; i++) {
      if (await _compareAssets(
        tuple.deleteCandidates[i],
        tuple.originals[i],
        apiService,
      )) {
        result.add(tuple.deleteCandidates[i]);
      }
    }
    return result;
  }

  static Future<bool> _compareAssets(
    Asset remote,
    Asset local,
    ApiService apiService,
  ) async {
    if (remote.checksum == local.checksum) return false;
    ExifInfo? exif = remote.exifInfo;
    if (exif != null && exif.lat != null) return false;
    if (exif == null || exif.fileSize == null) {
      final dto = await apiService.assetApi.getAssetById(remote.remoteId!);
      if (dto != null && dto.exifInfo != null) {
        exif = ExifInfo.fromDto(dto.exifInfo!);
      }
    }
    final file = await local.local!.originFile;
    if (exif != null && file != null && exif.fileSize != null) {
      final origSize = await file.length();
      if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
        final latLng = await local.local!.latlngAsync();

        if (exif.lat == null &&
            latLng.latitude != null &&
            (remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
                remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
                _sameExceptTimeZone(
                  remote.fileCreatedAt,
                  local.fileCreatedAt,
                ))) {
          if (remote.type == AssetType.video) {
            // it's very unlikely that a video of same length, filesize, name
            // and date is wrong match. Cannot easily compare videos anyway
            return true;
          }

          // for images: make sure they are pixel-wise identical
          // (skip first few KBs containing metadata)
          final Uint64List localImage =
              _fakeDecodeImg(local, await file.readAsBytes());
          final res = await apiService.assetApi
              .downloadFileWithHttpInfo(remote.remoteId!);
          final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);

          final eq = const ListEquality().equals(remoteImage, localImage);
          return eq;
        }
      }
    }

    return false;
  }

  static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
    const headerLength = 131072; // assume header is at most 128 KB
    final start = bytes.length < headerLength * 2
        ? (bytes.length ~/ (4 * 8)) * 8
        : headerLength;
    return bytes.buffer.asUint64List(start);
  }

  static Future<List<Asset>> _getMatches(
    QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
    int ownerId,
    List<Asset> assets,
    int limit,
  ) =>
      query
          .ownerIdEqualTo(ownerId)
          .anyOf(
            assets,
            (q, Asset a) => q
                .fileNameEqualTo(a.fileName)
                .and()
                .durationInSecondsEqualTo(a.durationInSeconds)
                .and()
                .fileCreatedAtBetween(
                  a.fileCreatedAt.subtract(const Duration(hours: 12)),
                  a.fileCreatedAt.add(const Duration(hours: 12)),
                )
                .and()
                .not()
                .checksumEqualTo(a.checksum),
          )
          .sortByFileName()
          .thenByFileCreatedAt()
          .thenByFileModifiedAt()
          .limit(limit)
          .findAll();

  static bool _sameExceptTimeZone(DateTime a, DateTime b) {
    final ms = a.isAfter(b)
        ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
        : b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
    final x = ms / (1000 * 60 * 30);
    final y = ms ~/ (1000 * 60 * 30);
    return y.toDouble() == x && y < 24;
  }
}

final backupVerificationServiceProvider = Provider(
  (ref) => BackupVerificationService(
    ref.watch(dbProvider),
  ),
);