import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;

import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;

final backgroundServiceProvider = Provider((ref) => BackgroundService());

/// Background backup service
class BackgroundService {
  static const String _portNameLock = "immichLock";
  static const MethodChannel _foregroundChannel = MethodChannel('immich/foregroundChannel');
  static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel');
  static const notifyInterval = Duration(milliseconds: 400);
  bool _isBackgroundInitialized = false;
  Completer<void>? _cancellationToken;
  bool _canceledBySystem = false;
  int _wantsLockTime = 0;
  bool _hasLock = false;
  SendPort? _waitingIsolate;
  ReceivePort? _rp;
  bool _errorGracePeriodExceeded = true;
  int _uploadedAssetsCount = 0;
  int _assetsToUploadCount = 0;
  String _lastPrintedDetailContent = "";
  String? _lastPrintedDetailTitle;
  late final ThrottleProgressUpdate _throttledNotifiy = ThrottleProgressUpdate(_updateProgress, notifyInterval);
  late final ThrottleProgressUpdate _throttledDetailNotify = ThrottleProgressUpdate(
    _updateDetailProgress,
    notifyInterval,
  );

  bool get isBackgroundInitialized {
    return _isBackgroundInitialized;
  }

  /// Ensures that the background service is enqueued if enabled in settings
  Future<bool> resumeServiceIfEnabled() async {
    return await isBackgroundBackupEnabled() && await enableService();
  }

  /// Enqueues the background service
  Future<bool> enableService({bool immediate = false}) async {
    try {
      final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
      final String title = "backup_background_service_default_notification".tr();
      final bool ok = await _foregroundChannel.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
      return ok;
    } catch (error) {
      return false;
    }
  }

  /// Configures the background service
  Future<bool> configureService({
    bool requireUnmetered = true,
    bool requireCharging = false,
    int triggerUpdateDelay = 5000,
    int triggerMaxDelay = 50000,
  }) async {
    try {
      final bool ok = await _foregroundChannel.invokeMethod('configure', [
        requireUnmetered,
        requireCharging,
        triggerUpdateDelay,
        triggerMaxDelay,
      ]);
      return ok;
    } catch (error) {
      return false;
    }
  }

  /// Cancels the background service (if currently running) and removes it from work queue
  Future<bool> disableService() async {
    try {
      final ok = await _foregroundChannel.invokeMethod('disable');
      return ok;
    } catch (error) {
      return false;
    }
  }

  /// Returns `true` if the background service is enabled
  Future<bool> isBackgroundBackupEnabled() async {
    try {
      return await _foregroundChannel.invokeMethod("isEnabled");
    } catch (error) {
      return false;
    }
  }

  /// Returns `true` if battery optimizations are disabled
  Future<bool> isIgnoringBatteryOptimizations() async {
    // iOS does not need battery optimizations enabled
    if (Platform.isIOS) {
      return true;
    }
    try {
      return await _foregroundChannel.invokeMethod('isIgnoringBatteryOptimizations');
    } catch (error) {
      return false;
    }
  }

  // Yet to be implemented
  Future<Uint8List?> digestFile(String path) {
    return _foregroundChannel.invokeMethod<Uint8List>("digestFile", [path]);
  }

  Future<List<Uint8List?>?> digestFiles(List<String> paths) {
    return _foregroundChannel.invokeListMethod<Uint8List?>("digestFiles", paths);
  }

  /// Updates the notification shown by the background service
  Future<bool?> _updateNotification({
    String? title,
    String? content,
    int progress = 0,
    int max = 0,
    bool indeterminate = false,
    bool isDetail = false,
    bool onlyIfFG = false,
  }) async {
    try {
      if (_isBackgroundInitialized) {
        return _backgroundChannel.invokeMethod<bool>('updateNotification', [
          title,
          content,
          progress,
          max,
          indeterminate,
          isDetail,
          onlyIfFG,
        ]);
      }
    } catch (error) {
      dPrint(() => "[_updateNotification] failed to communicate with plugin");
    }
    return false;
  }

  /// Shows a new priority notification
  Future<bool> _showErrorNotification({required String title, String? content, String? individualTag}) async {
    try {
      if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
        return await _backgroundChannel.invokeMethod('showError', [title, content, individualTag]);
      }
    } catch (error) {
      dPrint(() => "[_showErrorNotification] failed to communicate with plugin");
    }
    return false;
  }

  Future<bool> _clearErrorNotifications() async {
    try {
      if (_isBackgroundInitialized) {
        return await _backgroundChannel.invokeMethod('clearErrorNotifications');
      }
    } catch (error) {
      dPrint(() => "[_clearErrorNotifications] failed to communicate with plugin");
    }
    return false;
  }

  /// await to ensure this thread (foreground or background) has exclusive access
  Future<bool> acquireLock() async {
    if (_hasLock) {
      dPrint(() => "WARNING: [acquireLock] called more than once");
      return true;
    }
    final int lockTime = Timeline.now;
    _wantsLockTime = lockTime;
    final ReceivePort rp = ReceivePort(_portNameLock);
    _rp = rp;
    final SendPort sp = rp.sendPort;

    while (!IsolateNameServer.registerPortWithName(sp, _portNameLock)) {
      try {
        await _checkLockReleasedWithHeartbeat(lockTime);
      } catch (error) {
        return false;
      }
      if (_wantsLockTime != lockTime) {
        return false;
      }
    }
    _hasLock = true;
    rp.listen(_heartbeatListener);
    return true;
  }

  Future<void> _checkLockReleasedWithHeartbeat(final int lockTime) async {
    SendPort? other = IsolateNameServer.lookupPortByName(_portNameLock);
    if (other != null) {
      final ReceivePort tempRp = ReceivePort();
      final SendPort tempSp = tempRp.sendPort;
      final bs = tempRp.asBroadcastStream();
      while (_wantsLockTime == lockTime) {
        other.send(tempSp);
        final dynamic answer = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => null);
        if (_wantsLockTime != lockTime) {
          break;
        }
        if (answer == null) {
          // other isolate failed to answer, assuming it exited without releasing the lock
          if (other == IsolateNameServer.lookupPortByName(_portNameLock)) {
            IsolateNameServer.removePortNameMapping(_portNameLock);
          }
          break;
        } else if (answer == true) {
          // other isolate released the lock
          break;
        } else if (answer == false) {
          // other isolate is still active
        }
        final dynamic isFinished = await bs.first.timeout(const Duration(seconds: 3), onTimeout: () => false);
        if (isFinished == true) {
          break;
        }
      }
      tempRp.close();
    }
  }

  void _heartbeatListener(dynamic msg) {
    if (msg is SendPort) {
      _waitingIsolate = msg;
      msg.send(false);
    }
  }

  /// releases the exclusive access lock
  void releaseLock() {
    _wantsLockTime = 0;
    if (_hasLock) {
      IsolateNameServer.removePortNameMapping(_portNameLock);
      _waitingIsolate?.send(true);
      _waitingIsolate = null;
      _hasLock = false;
    }
    _rp?.close();
    _rp = null;
  }

  void _setupBackgroundCallHandler() {
    _backgroundChannel.setMethodCallHandler(_callHandler);
    _isBackgroundInitialized = true;
    _backgroundChannel.invokeMethod('initialized');
  }

  Future<bool> _callHandler(MethodCall call) async {
    DartPluginRegistrant.ensureInitialized();
    if (Platform.isIOS) {
      // NOTE: I'm not sure this is strictly necessary anymore, but
      // out of an abundance of caution, we will keep it in until someone
      // can say for sure
      PathProviderFoundation.registerWith();
    }
    switch (call.method) {
      case "backgroundProcessing":
      case "onAssetsChanged":
        try {
          unawaited(_clearErrorNotifications());

          // iOS should time out after some threshold so it doesn't wait
          // indefinitely and can run later
          // Android is fine to wait here until the lock releases
          final waitForLock = Platform.isIOS
              ? acquireLock().timeout(const Duration(seconds: 5), onTimeout: () => false)
              : acquireLock();

          final bool hasAccess = await waitForLock;
          if (!hasAccess) {
            dPrint(() => "[_callHandler] could not acquire lock, exiting");
            return false;
          }

          final translationsOk = await loadTranslations();
          if (!translationsOk) {
            dPrint(() => "[_callHandler] could not load translations");
          }

          final bool ok = await _onAssetsChanged();
          return ok;
        } catch (error) {
          dPrint(() => error.toString());
          return false;
        } finally {
          releaseLock();
        }
      case "systemStop":
        _canceledBySystem = true;
        _cancellationToken?.complete();
        _cancellationToken = null;
        return true;
      default:
        dPrint(() => "Unknown method ${call.method}");
        return false;
    }
  }

  Future<bool> _onAssetsChanged() async {
    final (isar, drift, logDb) = await Bootstrap.initDB();
    await Bootstrap.initDomain(isar, drift, logDb, shouldBufferLogs: false, listenStoreUpdates: false);

    final ref = ProviderContainer(
      overrides: [
        dbProvider.overrideWithValue(isar),
        isarProvider.overrideWithValue(isar),
        driftProvider.overrideWith(driftOverride(drift)),
      ],
    );

    await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
    dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");

    final selectedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.select);
    final excludedAlbums = await ref.read(backupAlbumRepositoryProvider).getAllBySelection(BackupSelection.exclude);
    if (selectedAlbums.isEmpty) {
      return true;
    }

    await ref.read(fileMediaRepositoryProvider).enableBackgroundAccess();

    do {
      final bool backupOk = await _runBackup(
        ref.read(backupServiceProvider),
        ref.read(appSettingsServiceProvider),
        selectedAlbums,
        excludedAlbums,
      );
      if (backupOk) {
        await Store.delete(StoreKey.backupFailedSince);
        final backupAlbums = [...selectedAlbums, ...excludedAlbums];
        backupAlbums.sortBy((e) => e.id);

        final dbAlbums = await ref.read(backupAlbumRepositoryProvider).getAll(sort: BackupAlbumSort.id);
        final List<int> toDelete = [];
        final List<BackupAlbum> toUpsert = [];
        // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
        diffSortedListsSync(
          dbAlbums,
          backupAlbums,
          compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
          both: (BackupAlbum a, BackupAlbum b) {
            a.lastBackup = a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
            toUpsert.add(a);
            return true;
          },
          onlyFirst: (BackupAlbum a) => toUpsert.add(a),
          onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
        );
        await ref.read(backupAlbumRepositoryProvider).deleteAll(toDelete);
        await ref.read(backupAlbumRepositoryProvider).updateAll(toUpsert);
      } else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
        await Store.put(StoreKey.backupFailedSince, DateTime.now());
        return false;
      }
      // Android should check for new assets added while performing backup
    } while (Platform.isAndroid && true == await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
    return true;
  }

  Future<bool> _runBackup(
    BackupService backupService,
    AppSettingsService settingsService,
    List<BackupAlbum> selectedAlbums,
    List<BackupAlbum> excludedAlbums,
  ) async {
    _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
    final bool notifyTotalProgress = settingsService.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
    final bool notifySingleProgress = settingsService.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);

    if (_canceledBySystem) {
      return false;
    }

    Set<BackupCandidate> toUpload = await backupService.buildUploadCandidates(selectedAlbums, excludedAlbums);

    try {
      toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
    } catch (e) {
      unawaited(
        _showErrorNotification(
          title: "backup_background_service_error_title".tr(),
          content: "backup_background_service_connection_failed_message".tr(),
        ),
      );
      return false;
    }

    if (_canceledBySystem) {
      return false;
    }

    if (toUpload.isEmpty) {
      return true;
    }
    _assetsToUploadCount = toUpload.length;
    _uploadedAssetsCount = 0;
    unawaited(
      _updateNotification(
        title: "backup_background_service_in_progress_notification".tr(),
        content: notifyTotalProgress ? formatAssetBackupProgress(_uploadedAssetsCount, _assetsToUploadCount) : null,
        progress: 0,
        max: notifyTotalProgress ? _assetsToUploadCount : 0,
        indeterminate: !notifyTotalProgress,
        onlyIfFG: !notifyTotalProgress,
      ),
    );

    _cancellationToken?.complete();
    _cancellationToken = Completer<void>();
    final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;

    final bool ok = await backupService.backupAsset(
      toUpload,
      _cancellationToken!,
      pmProgressHandler: pmProgressHandler,
      onSuccess: (result) => _onAssetUploaded(shouldNotify: notifyTotalProgress),
      onProgress: (bytes, totalBytes) => _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
      onCurrentAsset: (asset) => _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
      onError: _onBackupError,
      isBackground: true,
    );

    if (!ok && !_cancellationToken!.isCompleted) {
      unawaited(
        _showErrorNotification(
          title: "backup_background_service_error_title".tr(),
          content: "backup_background_service_backup_failed_message".tr(),
        ),
      );
    }

    return ok;
  }

  void _onAssetUploaded({bool shouldNotify = false}) {
    if (!shouldNotify) {
      return;
    }

    _uploadedAssetsCount++;
    _throttledNotifiy();
  }

  void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
    if (!shouldNotify) {
      return;
    }

    _throttledDetailNotify(progress: bytes, total: totalBytes);
  }

  void _updateDetailProgress(String? title, int progress, int total) {
    final String msg = total > 0 ? humanReadableBytesProgress(progress, total) : "";
    // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
    if (msg != _lastPrintedDetailContent || _lastPrintedDetailTitle != title) {
      _lastPrintedDetailContent = msg;
      _lastPrintedDetailTitle = title;
      _updateNotification(
        progress: total > 0 ? (progress * 1000) ~/ total : 0,
        max: 1000,
        isDetail: true,
        title: title,
        content: msg,
      );
    }
  }

  void _updateProgress(String? title, int progress, int total) {
    _updateNotification(
      progress: _uploadedAssetsCount,
      max: _assetsToUploadCount,
      title: title,
      content: formatAssetBackupProgress(_uploadedAssetsCount, _assetsToUploadCount),
    );
  }

  void _onBackupError(ErrorUploadAsset errorAssetInfo) {
    _showErrorNotification(
      title: "backup_background_service_upload_failure_notification".tr(
        namedArgs: {'filename': errorAssetInfo.fileName},
      ),
      individualTag: errorAssetInfo.id,
    );
  }

  void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset, {bool shouldNotify = false}) {
    if (!shouldNotify) {
      return;
    }

    _throttledDetailNotify.title = "backup_background_service_current_upload_notification".tr(
      namedArgs: {'filename': currentUploadAsset.fileName},
    );
    _throttledDetailNotify.progress = 0;
    _throttledDetailNotify.total = 0;
  }

  bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
    final int value = appSettingsService.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
    if (value == 0) {
      return true;
    } else if (value == 5) {
      return false;
    }
    final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
    if (failedSince == null) {
      return false;
    }
    final Duration duration = DateTime.now().difference(failedSince);
    if (value == 1) {
      return duration > const Duration(minutes: 30);
    } else if (value == 2) {
      return duration > const Duration(hours: 2);
    } else if (value == 3) {
      return duration > const Duration(hours: 8);
    } else if (value == 4) {
      return duration > const Duration(hours: 24);
    }
    assert(false, "Invalid value");
    return true;
  }

  Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
    if (!Platform.isIOS) {
      return null;
    }
    // Seconds since last run
    final double? lastRun = task == IosBackgroundTask.fetch
        ? await _foregroundChannel.invokeMethod('lastBackgroundFetchTime')
        : await _foregroundChannel.invokeMethod('lastBackgroundProcessingTime');
    if (lastRun == null) {
      return null;
    }
    final time = DateTime.fromMillisecondsSinceEpoch(lastRun.toInt() * 1000);
    return time;
  }

  Future<int> getIOSBackupNumberOfProcesses() async {
    if (!Platform.isIOS) {
      return 0;
    }
    return await _foregroundChannel.invokeMethod('numberOfBackgroundProcesses');
  }

  Future<bool> getIOSBackgroundAppRefreshEnabled() async {
    if (!Platform.isIOS) {
      return false;
    }
    return await _foregroundChannel.invokeMethod('backgroundAppRefreshEnabled');
  }
}

enum IosBackgroundTask { fetch, processing }

/// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() {
  WidgetsFlutterBinding.ensureInitialized();
  DartPluginRegistrant.ensureInitialized();
  BackgroundService backgroundService = BackgroundService();
  backgroundService._setupBackgroundCallHandler();
}
