import 'dart:async';

import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';

final assetServiceProvider = Provider(
  (ref) => AssetService(
    ref.watch(assetApiRepositoryProvider),
    ref.watch(assetRepositoryProvider),
    ref.watch(exifRepositoryProvider),
    ref.watch(userRepositoryProvider),
    ref.watch(etagRepositoryProvider),
    ref.watch(backupAlbumRepositoryProvider),
    ref.watch(apiServiceProvider),
    ref.watch(syncServiceProvider),
    ref.watch(backupServiceProvider),
    ref.watch(albumServiceProvider),
    ref.watch(userServiceProvider),
    ref.watch(assetMediaRepositoryProvider),
  ),
);

class AssetService {
  final AssetApiRepository _assetApiRepository;
  final AssetRepository _assetRepository;
  final IsarExifRepository _exifInfoRepository;
  final IsarUserRepository _isarUserRepository;
  final ETagRepository _etagRepository;
  final BackupAlbumRepository _backupRepository;
  final ApiService _apiService;
  final SyncService _syncService;
  final BackupService _backupService;
  final AlbumService _albumService;
  final UserService _userService;
  final AssetMediaRepository _assetMediaRepository;
  final log = Logger('AssetService');

  AssetService(
    this._assetApiRepository,
    this._assetRepository,
    this._exifInfoRepository,
    this._isarUserRepository,
    this._etagRepository,
    this._backupRepository,
    this._apiService,
    this._syncService,
    this._backupService,
    this._albumService,
    this._userService,
    this._assetMediaRepository,
  );

  /// Checks the server for updated assets and updates the local database if
  /// required. Returns `true` if there were any changes.
  Future<bool> refreshRemoteAssets() async {
    final syncedUserIds = await _etagRepository.getAllIds();
    final List<UserDto> syncedUsers = syncedUserIds.isEmpty
        ? []
        : (await _isarUserRepository.getByUserIds(syncedUserIds)).nonNulls.toList();
    final Stopwatch sw = Stopwatch()..start();
    final bool changes = await _syncService.syncRemoteAssetsToDb(
      users: syncedUsers,
      getChangedAssets: _getRemoteAssetChanges,
      loadAssets: _getRemoteAssets,
    );
    dPrint(() => "refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
    return changes;
  }

  /// Returns `(null, null)` if changes are invalid -> requires full sync
  Future<(List<Asset>? toUpsert, List<String>? toDelete)> _getRemoteAssetChanges(
    List<UserDto> users,
    DateTime since,
  ) async {
    final dto = AssetDeltaSyncDto(updatedAfter: since, userIds: users.map((e) => e.id).toList());
    final changes = await _apiService.syncApi.getDeltaSync(dto);
    return changes == null || changes.needsFullSync
        ? (null, null)
        : (changes.upserted.map(Asset.remote).toList(), changes.deleted);
  }

  /// Returns the list of people of the given asset id.
  // If the server is not reachable `null` is returned.
  Future<List<PersonWithFacesResponseDto>?> getRemotePeopleOfAsset(String remoteId) async {
    try {
      final AssetResponseDto? dto = await _apiService.assetsApi.getAssetInfo(remoteId);

      return dto?.people;
    } catch (error, stack) {
      log.severe('Error while getting remote asset info: ${error.toString()}', error, stack);

      return null;
    }
  }

  /// Returns `null` if the server state did not change, else list of assets
  Future<List<Asset>?> _getRemoteAssets(UserDto user, DateTime until) async {
    const int chunkSize = 10000;
    try {
      final List<Asset> allAssets = [];
      String? lastId;
      // will break on error or once all assets are loaded
      while (true) {
        final dto = AssetFullSyncDto(limit: chunkSize, updatedUntil: until, lastId: lastId, userId: user.id);
        log.fine("Requesting $chunkSize assets from $lastId");
        final List<AssetResponseDto>? assets = await _apiService.syncApi.getFullSyncForUser(dto);
        if (assets == null) return null;
        log.fine("Received ${assets.length} assets from ${assets.firstOrNull?.id} to ${assets.lastOrNull?.id}");
        allAssets.addAll(assets.map(Asset.remote));
        if (assets.length != chunkSize) break;
        lastId = assets.last.id;
      }
      return allAssets;
    } catch (error, stack) {
      log.severe('Error while getting remote assets', error, stack);
      return null;
    }
  }

  /// Loads the exif information from the database. If there is none, loads
  /// the exif info from the server (remote assets only)
  Future<Asset> loadExif(Asset a) async {
    a.exifInfo ??= (await _exifInfoRepository.get(a.id));
    // fileSize is always filled on the server but not set on client
    if (a.exifInfo?.fileSize == null) {
      if (a.isRemote) {
        final dto = await _apiService.assetsApi.getAssetInfo(a.remoteId!);
        if (dto != null && dto.exifInfo != null) {
          final newExif = Asset.remote(dto).exifInfo!.copyWith(assetId: a.id);
          a.exifInfo = newExif;
          if (newExif != a.exifInfo) {
            if (a.isInDb) {
              await _assetRepository.transaction(() => _assetRepository.update(a));
            } else {
              dPrint(() => "[loadExif] parameter Asset is not from DB!");
            }
          }
        }
      } else {
        // TODO implement local exif info parsing
      }
    }
    return a;
  }

  Future<void> updateAssets(List<Asset> assets, UpdateAssetDto updateAssetDto) async {
    return await _apiService.assetsApi.updateAssets(
      AssetBulkUpdateDto(
        ids: assets.map((e) => e.remoteId!).toList(),
        dateTimeOriginal: updateAssetDto.dateTimeOriginal,
        isFavorite: updateAssetDto.isFavorite,
        visibility: updateAssetDto.visibility,
        latitude: updateAssetDto.latitude,
        longitude: updateAssetDto.longitude,
      ),
    );
  }

  Future<List<Asset>> changeFavoriteStatus(List<Asset> assets, bool isFavorite) async {
    try {
      await updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite));

      for (var element in assets) {
        element.isFavorite = isFavorite;
      }

      await _syncService.upsertAssetsWithExif(assets);

      return assets;
    } catch (error, stack) {
      log.severe("Error while changing favorite status", error, stack);
      return [];
    }
  }

  Future<List<Asset>> changeArchiveStatus(List<Asset> assets, bool isArchived) async {
    try {
      await updateAssets(
        assets,
        UpdateAssetDto(visibility: isArchived ? AssetVisibility.archive : AssetVisibility.timeline),
      );

      for (var element in assets) {
        element.isArchived = isArchived;
        element.visibility = isArchived ? AssetVisibilityEnum.archive : AssetVisibilityEnum.timeline;
      }

      await _syncService.upsertAssetsWithExif(assets);

      return assets;
    } catch (error, stack) {
      log.severe("Error while changing archive status", error, stack);
      return [];
    }
  }

  Future<List<Asset>?> changeDateTime(List<Asset> assets, String updatedDt) async {
    try {
      await updateAssets(assets, UpdateAssetDto(dateTimeOriginal: updatedDt));

      for (var element in assets) {
        element.fileCreatedAt = DateTime.parse(updatedDt);
        element.exifInfo = element.exifInfo?.copyWith(dateTimeOriginal: DateTime.parse(updatedDt));
      }

      await _syncService.upsertAssetsWithExif(assets);

      return assets;
    } catch (error, stack) {
      log.severe("Error while changing date/time status", error, stack);
      return Future.value(null);
    }
  }

  Future<List<Asset>?> changeLocation(List<Asset> assets, LatLng location) async {
    try {
      await updateAssets(assets, UpdateAssetDto(latitude: location.latitude, longitude: location.longitude));

      for (var element in assets) {
        element.exifInfo = element.exifInfo?.copyWith(latitude: location.latitude, longitude: location.longitude);
      }

      await _syncService.upsertAssetsWithExif(assets);

      return assets;
    } catch (error, stack) {
      log.severe("Error while changing location status", error, stack);
      return Future.value(null);
    }
  }

  Future<void> syncUploadedAssetToAlbums() async {
    try {
      final selectedAlbums = await _backupRepository.getAllBySelection(BackupSelection.select);
      final excludedAlbums = await _backupRepository.getAllBySelection(BackupSelection.exclude);

      final candidates = await _backupService.buildUploadCandidates(
        selectedAlbums,
        excludedAlbums,
        useTimeFilter: false,
      );

      await refreshRemoteAssets();
      final owner = _userService.getMyUser();
      final remoteAssets = await _assetRepository.getAll(ownerId: owner.id, state: AssetState.merged);

      /// Map<AlbumName, [AssetId]>
      Map<String, List<String>> assetToAlbums = {};

      for (BackupCandidate candidate in candidates) {
        final asset = remoteAssets.firstWhereOrNull((a) => a.localId == candidate.asset.localId);

        if (asset != null) {
          for (final albumName in candidate.albumNames) {
            assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!);
          }
        }
      }

      // Upload assets to albums
      for (final entry in assetToAlbums.entries) {
        final albumName = entry.key;
        final assetIds = entry.value;

        await _albumService.syncUploadAlbums([albumName], assetIds);
      }
    } catch (error, stack) {
      log.severe("Error while syncing uploaded asset to albums", error, stack);
    }
  }

  Future<void> setDescription(Asset asset, String newDescription) async {
    final remoteAssetId = asset.remoteId;
    final localExifId = asset.exifInfo?.assetId;

    // Guard [remoteAssetId] and [localExifId] null
    if (remoteAssetId == null || localExifId == null) {
      return;
    }

    final result = await _assetApiRepository.update(remoteAssetId, description: newDescription);

    final description = result.exifInfo?.description;

    if (description != null) {
      var exifInfo = await _exifInfoRepository.get(localExifId);

      if (exifInfo != null) {
        await _exifInfoRepository.update(exifInfo.copyWith(description: description));
      }
    }
  }

  Future<String> getDescription(Asset asset) async {
    final localExifId = asset.exifInfo?.assetId;

    // Guard [remoteAssetId] and [localExifId] null
    if (localExifId == null) {
      return "";
    }

    final exifInfo = await _exifInfoRepository.get(localExifId);

    return exifInfo?.description ?? "";
  }

  Future<double> getAspectRatio(Asset asset) async {
    if (asset.isRemote) {
      asset = await loadExif(asset);
    } else if (asset.isLocal) {
      await asset.localAsync;
    }

    final aspectRatio = asset.aspectRatio;
    if (aspectRatio != null) {
      return aspectRatio;
    }

    final width = asset.width;
    final height = asset.height;
    if (width != null && height != null) {
      // we don't know the orientation, so assume it's normal
      return width / height;
    }

    return 1.0;
  }

  Future<List<Asset>> getStackAssets(String stackId) {
    return _assetRepository.getStackAssets(stackId);
  }

  Future<void> clearTable() {
    return _assetRepository.clearTable();
  }

  /// Delete assets from local file system and unreference from the database
  Future<void> deleteLocalAssets(Iterable<Asset> assets) async {
    // Delete files from local gallery
    final candidates = assets.where((asset) => asset.isLocal);

    final deletedIds = await _assetMediaRepository.deleteAll(candidates.map((asset) => asset.localId!).toList());

    // Modify local database by removing the reference to the local assets
    if (deletedIds.isNotEmpty) {
      // Delete records from local database
      final isarIds = assets.where((asset) => asset.storage == AssetState.local).map((asset) => asset.id).toList();
      await _assetRepository.deleteByIds(isarIds);

      // Modify Merged asset to be remote only
      final updatedAssets = assets.where((asset) => asset.storage == AssetState.merged).map((asset) {
        asset.localId = null;
        return asset;
      }).toList();

      await _assetRepository.updateAll(updatedAssets);
    }
  }

  /// Delete assets from the server and unreference from the database
  Future<void> deleteRemoteAssets(Iterable<Asset> assets, {bool shouldDeletePermanently = false}) async {
    final candidates = assets.where((a) => a.isRemote);

    if (candidates.isEmpty) {
      return;
    }

    await _apiService.assetsApi.deleteAssets(
      AssetBulkDeleteDto(ids: candidates.map((a) => a.remoteId!).toList(), force: shouldDeletePermanently),
    );

    /// Update asset info bassed on the deletion type.
    final payload = shouldDeletePermanently
        ? assets.where((asset) => asset.storage == AssetState.merged).map((asset) {
            asset.remoteId = null;
            asset.visibility = AssetVisibilityEnum.timeline;
            return asset;
          })
        : assets.where((asset) => asset.isRemote).map((asset) {
            asset.isTrashed = true;
            return asset;
          });

    await _assetRepository.transaction(() async {
      await _assetRepository.updateAll(payload.toList());

      if (shouldDeletePermanently) {
        final remoteAssetIds = assets
            .where((asset) => asset.storage == AssetState.remote)
            .map((asset) => asset.id)
            .toList();
        await _assetRepository.deleteByIds(remoteAssetIds);
      }
    });
  }

  /// Delete assets on both local file system and the server.
  /// Unreference from the database.
  Future<void> deleteAssets(Iterable<Asset> assets, {bool shouldDeletePermanently = false}) async {
    final hasLocal = assets.any((asset) => asset.isLocal);
    final hasRemote = assets.any((asset) => asset.isRemote);

    if (hasLocal) {
      await deleteLocalAssets(assets);
    }

    if (hasRemote) {
      await deleteRemoteAssets(assets, shouldDeletePermanently: shouldDeletePermanently);
    }
  }

  Stream<Asset?> watchAsset(int id, {bool fireImmediately = false}) {
    return _assetRepository.watchAsset(id, fireImmediately: fireImmediately);
  }

  Future<List<Asset>> getRecentlyTakenAssets() {
    final me = _userService.getMyUser();
    return _assetRepository.getRecentlyTakenAssets(me.id);
  }

  Future<List<Asset>> getMotionAssets() {
    final me = _userService.getMyUser();
    return _assetRepository.getMotionAssets(me.id);
  }

  Future<void> setVisibility(List<Asset> assets, AssetVisibilityEnum visibility) async {
    await _assetApiRepository.updateVisibility(assets.map((asset) => asset.remoteId!).toList(), visibility);

    final updatedAssets = assets.map((asset) {
      asset.visibility = visibility;
      return asset;
    }).toList();

    await _assetRepository.updateAll(updatedAssets);
  }

  Future<Asset?> getAssetByRemoteId(String remoteId) async {
    final assets = await _assetRepository.getAllByRemoteId([remoteId]);
    return assets.isNotEmpty ? assets.first : null;
  }
}
