import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:background_downloader/background_downloader.dart' as bd;
import 'package:flutter/foundation.dart';

import '../../api/marianumcloud/webdav/webdav_api.dart';
import '../../model/account_data.dart';
import '../../notification/notification_service.dart';
import '../../share_intent/remote_file_ref.dart';
import 'download_job.dart';

/// Central registry for file downloads, shared between the Files list and Talk
/// chat bubbles. Backed by `background_downloader` so downloads run in a native
/// foreground service (Android) / URLSession background session (iOS) and
/// therefore keep running — with a system progress notification — while the app
/// is backgrounded or the user works elsewhere, even for large files.
///
/// Jobs are keyed by remote (WebDAV) path so a file triggered from multiple
/// screens reuses one job. The in-memory registry is not rebuilt across a full
/// app restart; a still-running native download keeps going and its completion
/// notification stays tappable (rebuilt on demand in [_onNotificationTap]).
class DownloadManager {
  DownloadManager._();
  static final DownloadManager instance = DownloadManager._();

  /// Callback group for our tasks — scopes the notification-tap callback so it
  /// only fires for downloads we started.
  static const _group = 'mm_downloads';

  /// Files land in the app's private temp area under this subdirectory, mirror-
  /// ing the previous behaviour. Export to the device happens via the viewer's
  /// "Teilen"/"Speichern" actions.
  static const _directory = 'downloads';

  final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
  final Map<String, DownloadJob> _byTaskId = {};

  /// All jobs the user should currently see in the downloads tray/overview:
  /// everything that is in progress or finished-but-not-yet-opened (failed
  /// downloads linger so the user can retry; cancelled ones drop out).
  final ValueNotifier<List<DownloadJob>> visibleJobs = ValueNotifier(const []);

  /// Emits whenever a job reaches [DownloadDone]. The downloads tray listens to
  /// decide between auto-opening (single download) and parking it in the
  /// overview (parallel downloads).
  final StreamController<DownloadJob> _completions =
      StreamController<DownloadJob>.broadcast();
  Stream<DownloadJob> get completions => _completions.stream;

  /// Set when the user taps a completion notification; consumed by the
  /// downloads tray to open the file (also covers cold-start taps).
  final ValueNotifier<DownloadJob?> pendingOpen = ValueNotifier(null);

  bool _initialized = false;
  StreamSubscription<bd.TaskUpdate>? _updatesSub;

  /// Wires up the native downloader once. Safe to call repeatedly.
  Future<void> initialize() async {
    if (_initialized) return;
    _initialized = true;

    _updatesSub = bd.FileDownloader().updates.listen(_onUpdate);
    bd.FileDownloader().registerCallbacks(
      group: _group,
      taskNotificationTapCallback: _onNotificationTap,
    );
    // Run larger downloads in a native foreground service: this lifts the
    // default 9-minute WorkManager timeout and greatly improves the odds of a
    // big file finishing while the app sits in the background. Smaller files
    // stay on the lighter WorkManager path. Requires the running notification
    // configured below + the manifest FOREGROUND_SERVICE(_DATA_SYNC) entries.
    await bd.FileDownloader().configure(
      androidConfig: (bd.Config.runInForegroundIfFileLargerThan, 10),
    );

    bd.FileDownloader().configureNotification(
      running: const bd.TaskNotification(
        '{filename}',
        'Wird heruntergeladen … {progress}',
      ),
      complete: const bd.TaskNotification(
        '{filename}',
        'Fertig – tippen zum Öffnen',
      ),
      error: const bd.TaskNotification('{filename}', 'Download fehlgeschlagen'),
      progressBar: true,
      // We route taps into the in-app viewer ourselves.
      tapOpensFile: false,
    );

    // Deliberately no notification-permission request here: it would fire the
    // OS prompt at cold start (before login), which is exactly what we moved
    // into the guided first-Talk-visit flow (see maybePromptTalkNotifications).
    // Downloads still work without it; their progress notifications simply
    // appear once the same POST_NOTIFICATIONS permission is granted there.
  }

  /// Active or recently finished job for [remotePath], or null if none.
  DownloadJob? jobFor(String remotePath) => _jobs[remotePath];

  /// Returns the existing in-flight job for [remotePath], otherwise enqueues a
  /// new one. Caller listens on [DownloadJob.status] for inline UI.
  Future<DownloadJob> start({
    required String remotePath,
    required String name,
    RemoteFileRef? remoteFile,
  }) async {
    await initialize();

    final existing = _jobs[remotePath];
    if (existing != null && !existing.isFinished) return existing;
    if (existing != null) _remove(existing);

    final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
    final task = bd.DownloadTask(
      url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
      headers: AccountData().authHeaders(),
      filename: name,
      baseDirectory: bd.BaseDirectory.temporary,
      directory: _directory,
      group: _group,
      updates: bd.Updates.statusAndProgress,
      allowPause: true,
      metaData: _encodeMeta(remotePath, name, remoteFile),
    );

    final job = DownloadJob(
      remotePath: remotePath,
      name: name,
      remoteFile: remoteFile,
    )..taskId = task.taskId;
    _jobs[remotePath] = job;
    _byTaskId[task.taskId] = job;
    _refreshVisible();

    final ok = await bd.FileDownloader().enqueue(task);
    if (!ok && !job.isFinished) {
      _fail(job, 'Download konnte nicht gestartet werden.');
    }
    return job;
  }

  /// Cancels an in-flight download. The native task is cancelled and the job
  /// transitions to [DownloadCancelled] (removed from the tray).
  Future<void> cancel(DownloadJob job) async {
    final taskId = job.taskId;
    if (taskId != null) {
      await bd.FileDownloader().cancelTaskWithId(taskId);
    }
    if (!job.isFinished) job.status.value = const DownloadCancelled();
    _remove(job);
  }

  /// Re-enqueues a failed job under the same remote path.
  Future<DownloadJob> retry(DownloadJob job) {
    _remove(job);
    return start(
      remotePath: job.remotePath,
      name: job.name,
      remoteFile: job.remoteFile,
    );
  }

  /// Marks a finished job as opened and drops it from the tray. Also dismisses
  /// the lingering "download complete" system notification for that file.
  void markOpened(DownloadJob job) {
    job.opened = true;
    _dismissNotification(job);
    _remove(job);
  }

  /// Cancels the task's completion notification. background_downloader derives
  /// its Android notification id from the Java `String.hashCode()` of the task
  /// id, which we reproduce to target the exact notification. Android-only —
  /// iOS uses a different identifier scheme.
  void _dismissNotification(DownloadJob job) {
    final taskId = job.taskId;
    if (taskId == null || !Platform.isAndroid) return;
    final id = _androidNotificationId(taskId);
    // background_downloader pushes the completion status to us *before* it posts
    // the "Fertig" notification, and that post runs through a queue throttled to
    // one notification per ~300ms. When the file opens immediately (lone
    // foreground download), our first cancel therefore races ahead of the
    // notification actually appearing and no-ops — leaving it stuck. Re-cancel
    // across the throttle window so the notification is caught once it lands.
    for (final delay in _dismissRetryDelays) {
      if (delay == Duration.zero) {
        _cancelNotification(id);
      } else {
        Future<void>.delayed(delay, () => _cancelNotification(id));
      }
    }
  }

  static const _dismissRetryDelays = [
    Duration.zero,
    Duration(milliseconds: 350),
    Duration(milliseconds: 750),
    Duration(milliseconds: 1500),
  ];

  void _cancelNotification(int id) {
    unawaited(
      NotificationService().flutterLocalNotificationsPlugin
          .cancel(id: id)
          .catchError((Object _) {}),
    );
  }

  /// Java/Kotlin `String.hashCode()` (32-bit signed) — matches the id
  /// background_downloader posts its notification under.
  int _androidNotificationId(String taskId) {
    var hash = 0;
    for (final unit in taskId.codeUnits) {
      hash = (31 * hash + unit) & 0xFFFFFFFF;
    }
    if (hash >= 0x80000000) hash -= 0x100000000;
    return hash;
  }

  /// Removes a finished job from the tray without opening (swipe-to-dismiss).
  void dismiss(DownloadJob job) => _remove(job);

  /// Clears the whole tray at once (swipe the chip away): cancels anything still
  /// running and drops every job. The registry empties synchronously so the UI
  /// updates immediately; native cancellations fire in the background.
  void clearAll() {
    for (final job in _jobs.values.toList()) {
      final taskId = job.taskId;
      if (!job.isFinished) {
        if (taskId != null) {
          unawaited(bd.FileDownloader().cancelTaskWithId(taskId));
        }
        // Notify inline listeners (list/bubble) so their progress bars clear
        // instead of freezing at the last percent.
        job.status.value = const DownloadCancelled();
      }
      _jobs.remove(job.remotePath);
      if (taskId != null) _byTaskId.remove(taskId);
      scheduleMicrotask(job.dispose);
    }
    _refreshVisible();
  }

  // --- native update handling ------------------------------------------------

  void _onUpdate(bd.TaskUpdate update) {
    final job = _byTaskId[update.task.taskId];
    if (job == null || job.isFinished) return;

    switch (update) {
      case bd.TaskStatusUpdate():
        switch (update.status) {
          case bd.TaskStatus.complete:
            unawaited(_complete(job, update.task));
          case bd.TaskStatus.failed:
          case bd.TaskStatus.notFound:
            _fail(
              job,
              update.exception?.description ??
                  (update.status == bd.TaskStatus.notFound
                      ? 'Datei auf dem Server nicht gefunden.'
                      : 'Download fehlgeschlagen.'),
            );
          case bd.TaskStatus.canceled:
            job.status.value = const DownloadCancelled();
            _remove(job);
          case bd.TaskStatus.enqueued:
          case bd.TaskStatus.running:
          case bd.TaskStatus.waitingToRetry:
          case bd.TaskStatus.paused:
            break; // progress is delivered separately
        }
      case bd.TaskProgressUpdate():
        // Negative values are sentinels (failed/canceled/…) handled via status.
        final progress = update.progress;
        job.status.value = DownloadInProgress(
          progress >= 0 && progress <= 1 ? progress * 100 : 0,
        );
    }
  }

  Future<void> _complete(DownloadJob job, bd.Task task) async {
    if (job.isFinished) return;
    final path = await task.filePath();
    if (job.isFinished) return;
    job.status.value = DownloadDone(path);
    _refreshVisible();
    _completions.add(job);
  }

  void _fail(DownloadJob job, String message) {
    if (job.isFinished) return;
    job.status.value = DownloadFailed(message);
    _refreshVisible();
  }

  Future<void> _onNotificationTap(
    bd.Task task,
    bd.NotificationType notificationType,
  ) async {
    if (notificationType != bd.NotificationType.complete) return;
    final job = _byTaskId[task.taskId] ?? await _rebuildJob(task);
    if (job == null) return;
    pendingOpen.value = job;
  }

  /// Rebuilds a finished [DownloadJob] straight from a native task — used when a
  /// completion notification is tapped after the app (and thus the in-memory
  /// registry) was gone.
  Future<DownloadJob?> _rebuildJob(bd.Task task) async {
    try {
      final meta = _decodeMeta(task.metaData);
      final path = await task.filePath();
      final job = DownloadJob(
        remotePath: meta?.remotePath ?? '',
        name: task.filename,
        remoteFile: meta?.remoteFile,
      )..taskId = task.taskId;
      job.status.value = DownloadDone(path);
      return job;
    } on Object catch (e) {
      debugPrint('DownloadManager: could not rebuild tapped task: $e');
      return null;
    }
  }

  // --- bookkeeping -----------------------------------------------------------

  void _remove(DownloadJob job) {
    _jobs.remove(job.remotePath);
    final taskId = job.taskId;
    if (taskId != null) _byTaskId.remove(taskId);
    _refreshVisible();
    scheduleMicrotask(job.dispose);
  }

  void _refreshVisible() {
    visibleJobs.value = _jobs.values
        .where((j) => !j.opened && !j.isCancelled)
        .toList(growable: false);
  }

  // --- metadata (remote ref) serialisation ----------------------------------

  String _encodeMeta(String remotePath, String name, RemoteFileRef? ref) =>
      jsonEncode({
        'remotePath': remotePath,
        'name': name,
        if (ref != null)
          'ref': {
            'path': ref.path,
            'name': ref.name,
            'fileId': ref.fileId,
            'hasPreview': ref.hasPreview,
          },
      });

  ({String remotePath, RemoteFileRef? remoteFile})? _decodeMeta(String meta) {
    if (meta.isEmpty) return null;
    final map = jsonDecode(meta) as Map<String, dynamic>;
    final ref = map['ref'] as Map<String, dynamic>?;
    return (
      remotePath: (map['remotePath'] as String?) ?? '',
      remoteFile: ref == null
          ? null
          : RemoteFileRef(
              path: ref['path'] as String,
              name: ref['name'] as String,
              fileId: ref['fileId'] as int?,
              hasPreview: ref['hasPreview'] as bool?,
            ),
    );
  }

  @visibleForTesting
  void debugDispose() {
    _updatesSub?.cancel();
    _initialized = false;
  }
}
