import 'dart:io';
import 'dart:isolate';
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';

const _kInstallChannel = MethodChannel('chat.pyramid.pyramid/install');

// ─── Stable notification ID per room (same algorithm as PushService.kt) ──────
// Dart's String.hashCode is randomised per-process; this gives a deterministic
// integer that matches the Kotlin side so FCM and sync notifications collapse.
int _stableRoomId(String roomId) {
  var h = 0;
  for (final c in roomId.codeUnits) {
    h = ((h * 31) + c) & 0x7FFFFFFF;
  }
  return h == 0 ? 1 : h;
}

// ─── Android plugin ───────────────────────────────────────────────────────────

final _androidPlugin = FlutterLocalNotificationsPlugin();
bool _androidInitialized = false;

// ─── IsolateNameServer port ───────────────────────────────────────────────────
// Registered on startup so background isolates can forward notification actions
// to the running main isolate instead of starting a new Flutter engine.
ReceivePort? _mainIsolatePort;

// Room → active Windows notification (so we can close it on room-open).
final _windowsNotifs = <String, LocalNotification>{};

// ─── Notification tap payload → navigate to room ──────────────────────────────

final notificationTapProvider = StateProvider<String?>((ref) => null);

// ─── Permission state (Android) ───────────────────────────────────────────────

/// True when the user has permanently denied notification permission on Android.
final notificationsBlockedProvider = StateProvider<bool>((ref) => false);

// ─── Init ─────────────────────────────────────────────────────────────────────

Future<void> initNotifications(WidgetRef ref) async {
  if (Platform.isAndroid) {
    await _initAndroid(ref);
  } else if (Platform.isWindows) {
    await _initWindows();
  }
}

Future<void> _initAndroid(WidgetRef ref) async {
  if (_androidInitialized) return;
  _androidInitialized = true;

  // Register the IsolateNameServer port so background isolates can forward
  // notification actions to us instead of starting a new Flutter engine.
  _mainIsolatePort?.close();
  _mainIsolatePort = ReceivePort();
  IsolateNameServer.removePortNameMapping(kMainIsolateName);
  IsolateNameServer.registerPortWithName(
      _mainIsolatePort!.sendPort, kMainIsolateName);
  _mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
  debugPrint('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');

  await _androidPlugin.initialize(
    const InitializationSettings(
      android: AndroidInitializationSettings('@drawable/ic_notification'),
    ),
    onDidReceiveNotificationResponse: (details) {
      debugPrint('[NOTIF-FG] onDidReceiveNotificationResponse fired');
      debugPrint('[NOTIF-FG]   actionId=${details.actionId}  payload=${details.payload}  input=${details.input}');
      if (details.actionId == 'reply') {
        final text = details.input?.trim() ?? '';
        final roomId = details.payload;
        debugPrint('[NOTIF-FG] reply: text="$text" roomId=$roomId');
        if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) {
          _androidPlugin.cancel(_stableRoomId(roomId));
          ref.read(matrixClientProvider.future).then((client) async {
            final room = client.getRoomById(roomId);
            debugPrint('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
            if (room != null) {
              try {
                await room.sendTextEvent(text);
                debugPrint('[NOTIF-FG] sendTextEvent OK');
              } catch (e) {
                debugPrint('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs');
                final prefs = await SharedPreferences.getInstance();
                await prefs.setString('notif_pending_reply_room', roomId);
                await prefs.setString('notif_pending_reply_text', text);
              }
            } else {
              debugPrint('[NOTIF-FG] room null — storing in prefs, waiting for sync');
              final prefs = await SharedPreferences.getInstance();
              await prefs.setString('notif_pending_reply_room', roomId);
              await prefs.setString('notif_pending_reply_text', text);
              try {
                await client.onSync.stream.first;
                debugPrint('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
                await _sendPendingReply(ref);
              } catch (e) {
                debugPrint('[NOTIF-FG] sync/send error: $e');
              }
            }
          });
        } else {
          debugPrint('[NOTIF-FG] reply skipped: text empty or roomId null');
        }
        return;
      }
      if (details.actionId == 'read') {
        final roomId = details.payload;
        if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
        debugPrint('[NOTIF-FG] read action — dismissed');
        return;
      }
      final roomId = details.payload;
      debugPrint('[NOTIF-FG] tap → opening room $roomId');
      if (roomId != null && roomId.isNotEmpty) {
        ref.read(activeRoomIdProvider.notifier).state = roomId;
        ref.read(viewModeProvider.notifier).state = ViewMode.chat;
      }
    },
    // Registers the background action handler so flutter_local_notifications
    // can invoke it in a fresh isolate when the app is killed and the user
    // presses Reply or Gelesen on a background notification.
    onDidReceiveBackgroundNotificationResponse:
        handleBackgroundNotificationResponse,
  );

  // Handle notification taps from PushService.kt (native FCM notifications).
  // "openRoom" arrives when app is already running and user taps a notification.
  _kInstallChannel.setMethodCallHandler((call) async {
    final args = call.arguments;
    if (call.method == 'openRoom') {
      final roomId = (args is Map ? args['room_id'] : null) as String?;
      debugPrint('[NOTIF-FG] openRoom: $roomId');
      if (roomId != null && roomId.isNotEmpty) {
        ref.read(activeRoomIdProvider.notifier).state = roomId;
        ref.read(viewModeProvider.notifier).state = ViewMode.chat;
      }
      await _sendPendingReply(ref);
    } else if (call.method == 'replyFromNotification') {
      // Native Android path: ReplyReceiver forwarded the inline reply text via
      // MethodChannel — no Activity was opened.
      final roomId = (args is Map ? args['room_id'] : null) as String?;
      final text   = (args is Map ? args['text']    : null) as String?;
      debugPrint('[NOTIF-FG] replyFromNotification: room=$roomId text=$text');
      if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
        _androidPlugin.cancel(_stableRoomId(roomId));
        Future<void> storePending() async {
          final prefs = await SharedPreferences.getInstance();
          await prefs.setString('notif_pending_reply_room', roomId);
          await prefs.setString('notif_pending_reply_text', text);
        }

        try {
          final client = await ref.read(matrixClientProvider.future);
          final room = client.getRoomById(roomId);
          if (room != null) {
            await room.sendTextEvent(text);
            debugPrint('[NOTIF-FG] replyFromNotification: sent OK');
          } else {
            debugPrint('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
            await storePending();
            // Retry on the next sync — room list is populated after first sync.
            client.onSync.stream.first
                .then((_) => _sendPendingReply(ref))
                .catchError((_) {});
          }
        } catch (e) {
          debugPrint('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
          await storePending();
          // Resume-based retry (checkPendingReply in app.dart) will pick this up.
        }
      }
    } else if (call.method == 'sharedContent') {
      // "Teilen nach Pyramid" während die App läuft — AppShell zeigt den Picker.
      final text = (args is Map ? args['text'] : null) as String?;
      final paths = (args is Map ? args['paths'] : null);
      final share = PendingShare(
        text: text,
        paths: paths is List ? paths.whereType<String>().toList() : const [],
      );
      if (!share.isEmpty) {
        ref.read(pendingShareProvider.notifier).state = share;
      }
    } else if (call.method == 'decryptAndUpdateNotification') {
      // Called by PushService.kt after showing a "Neue Nachricht" placeholder.
      // We decrypt the event here (the Matrix client has the keys) and overwrite
      // the notification with the real sender name and message body.
      final roomId  = (args is Map ? args['room_id']  : null) as String?;
      final eventId = (args is Map ? args['event_id'] : null) as String?;
      final notifId = (args is Map ? args['notif_id'] : null) as int?;
      if (roomId != null && roomId.isNotEmpty) {
        _decryptAndUpdateNotification(ref, roomId, eventId, notifId).catchError((_) {});
      }
    }
  });

  // Cold-start tap: app was killed, user tapped notification → check launch intent.
  try {
    final roomId = await _kInstallChannel.invokeMethod<String?>('getInitialRoomId');
    if (roomId != null && roomId.isNotEmpty) {
      ref.read(activeRoomIdProvider.notifier).state = roomId;
      ref.read(viewModeProvider.notifier).state = ViewMode.chat;
    }
  } catch (_) {}

  // Cold-start reply: app was killed, user pressed Reply → reply text is in the
  // launch intent (onCreate fires instead of onNewIntent when app is killed).
  try {
    final reply = await _kInstallChannel.invokeMethod<Object?>('getInitialReply');
    if (reply is Map) {
      final roomId = reply['room_id'] as String?;
      final text   = reply['text']    as String?;
      debugPrint('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text');
      if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
        // Persist so _sendPendingReply sends it after the first sync.
        final prefs = await SharedPreferences.getInstance();
        await prefs.setString('notif_pending_reply_room', roomId);
        await prefs.setString('notif_pending_reply_text', text);
        // Send the app back to background immediately — the user replied from
        // the notification and doesn't want the full UI to appear.
        try {
          await _kInstallChannel.invokeMethod('minimizeApp');
        } catch (_) {}
      }
    }
  } catch (_) {}

  // Cold-start share: app was launched via "Teilen nach Pyramid".
  try {
    final share = await _kInstallChannel.invokeMethod<Object?>('getInitialShare');
    if (share is Map) {
      final text = share['text'] as String?;
      final paths = share['paths'];
      final pending = PendingShare(
        text: text,
        paths: paths is List ? paths.whereType<String>().toList() : const [],
      );
      if (!pending.isEmpty) {
        ref.read(pendingShareProvider.notifier).state = pending;
      }
    }
  } catch (_) {}

  final androidPlugin = _androidPlugin
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>();

  // Pre-create channel so it exists before the FCM background isolate runs.
  await androidPlugin?.createNotificationChannel(
    const AndroidNotificationChannel(
      'pyramid_messages',
      'Nachrichten',
      description: 'Neue Nachrichten',
      importance: Importance.high,
      enableVibration: true,
      playSound: true,
    ),
  );

  final granted = await androidPlugin?.requestNotificationsPermission();
  if (granted == false) {
    ref.read(notificationsBlockedProvider.notifier).state = true;
  }

  // FCM: Firebase initialisieren + Pusher beim Homeserver registrieren.
  // Use .future so we wait for the Matrix client to be fully ready
  // rather than potentially getting null from .value during startup.
  try {
    final client = await ref.read(matrixClientProvider.future);
    await initFcm(client);
    // Rooms are only reliably in memory after the first sync completes.
    // Send any reply that was stored while the app was killed.
    client.onSync.stream.first
        .then((_) => _sendPendingReply(ref))
        .catchError((_) {});
  } catch (_) {}
}

// ─── IsolateNameServer message handler ───────────────────────────────────────
// Called when a background isolate (handleBackgroundNotificationResponse)
// forwards a notification action to the running main isolate.

void _onIsolateMessage(dynamic message, WidgetRef ref) {
  if (message is! Map) return;
  final actionId = message['actionId'] as String?;
  final payload  = message['payload']  as String?;
  final input    = message['input']    as String?;
  final id       = message['id']       as int?;

  debugPrint('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input');

  switch (actionId) {
    case 'reply':
      final roomId = payload;
      final text   = (input ?? '').trim();
      if (roomId == null || roomId.isEmpty || text.isEmpty) return;
      _androidPlugin.cancel(id ?? _stableRoomId(roomId));
      ref.read(matrixClientProvider.future).then((client) async {
        final room = client.getRoomById(roomId);
        if (room != null) {
          try {
            await room.sendTextEvent(text);
            debugPrint('[NOTIF-ISOLATE] reply sent OK');
          } catch (e) {
            debugPrint('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs');
            final prefs = await SharedPreferences.getInstance();
            await prefs.setString('notif_pending_reply_room', roomId);
            await prefs.setString('notif_pending_reply_text', text);
            client.onSync.stream.first
                .then((_) => _sendPendingReply(ref))
                .catchError((_) {});
          }
        } else {
          debugPrint('[NOTIF-ISOLATE] room not in memory — storing in prefs');
          final prefs = await SharedPreferences.getInstance();
          await prefs.setString('notif_pending_reply_room', roomId);
          await prefs.setString('notif_pending_reply_text', text);
          client.onSync.stream.first
              .then((_) => _sendPendingReply(ref))
              .catchError((_) {});
        }
      });

    case 'read':
      if (payload != null) {
        _androidPlugin.cancel(_stableRoomId(payload));
      } else if (id != null) {
        _androidPlugin.cancel(id);
      }
      debugPrint('[NOTIF-ISOLATE] read → notification dismissed');

    default:
      // Plain notification tap → navigate to room.
      if (payload != null && payload.isNotEmpty) {
        ref.read(activeRoomIdProvider.notifier).state = payload;
        ref.read(viewModeProvider.notifier).state = ViewMode.chat;
        debugPrint('[NOTIF-ISOLATE] tap → navigated to $payload');
      }
  }
}

/// Sends a reply stored by the background notification handler.
/// Called on cold start and on every app resume so no reply is ever lost.
Future<void> _sendPendingReply(WidgetRef ref) async {
  try {
    final prefs = await SharedPreferences.getInstance();
    final roomId = prefs.getString('notif_pending_reply_room');
    final text   = prefs.getString('notif_pending_reply_text');
    debugPrint('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text');
    if (roomId == null || text == null || text.isEmpty) return;

    final client = await ref.read(matrixClientProvider.future);
    debugPrint('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}');

    final room = client.getRoomById(roomId);
    debugPrint('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
    if (room == null) {
      debugPrint('[NOTIF-REPLY] room not in memory yet, retrying after next sync');
      client.onSync.stream.first
          .then((_) => _sendPendingReply(ref))
          .catchError((_) {});
      return;
    }

    await room.sendTextEvent(text);
    debugPrint('[NOTIF-REPLY] sendTextEvent OK, clearing prefs');
    await prefs.remove('notif_pending_reply_room');
    await prefs.remove('notif_pending_reply_text');
  } catch (e) {
    debugPrint('[NOTIF-REPLY] error: $e — prefs kept for retry');
  }
}

/// Opens the OS notification-settings screen for this app (Android only).
Future<void> openNotificationSettings() async {
  if (!Platform.isAndroid) return;
  await _kInstallChannel.invokeMethod('openNotificationSettings');
}

/// Opens the system battery optimisation dialog for Pyramid (Android only).
/// Samsung and other OEMs kill FCM background processes unless this is set
/// to "Unrestricted".
Future<void> openBatteryOptimizationSettings() async {
  if (!Platform.isAndroid) return;
  await _kInstallChannel.invokeMethod('openBatterySettings');
}

Future<void> _initWindows() async {
  await localNotifier.setup(appName: 'Pyramid');
}

// ─── Heartbeat — tells the FCM background handler the app is running ──────────

bool _appInForeground = true;

/// Call from the app lifecycle observer in app.dart.
void setAppForeground(bool value) {
  _appInForeground = value;
  if (!value && Platform.isAndroid) {
    // Clear immediately so the FCM background handler runs as soon as the app
    // is no longer in the foreground / is killed.
    SharedPreferences.getInstance()
        .then((p) => p.setInt('notif_app_heartbeat', 0));
  }
}

void _pingHeartbeat() {
  if (!Platform.isAndroid || !_appInForeground) return;
  SharedPreferences.getInstance().then(
    (p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
  );
}

// ─── Reset notification timestamp on app resume ───────────────────────────────
// Events that arrived while the app was backgrounded were already notified by
// the FCM background handler. Update _watchStartTime so the notification
// watcher skips those catch-up events when the app comes back to foreground.

void resetNotificationStartTime() {
  _watchStartTime = DateTime.now();
}

// ─── Cancel notification for a room (call when the chat is opened) ────────────

Future<void> cancelNotificationForRoom(String roomId) async {
  if (Platform.isAndroid && _androidInitialized) {
    await _androidPlugin.cancel(_stableRoomId(roomId));
  } else if (Platform.isWindows) {
    final notif = _windowsNotifs.remove(roomId);
    notif?.close();
  }
}

// ─── Decrypt & update notification (called by PushService via MethodChannel) ──
// PushService shows a "Neue Nachricht" placeholder immediately. This function
// is called while the app is backgrounded — the Matrix client has the E2EE keys
// so it can decrypt the event and overwrite the placeholder with real content.

Future<void> _decryptAndUpdateNotification(
    WidgetRef ref, String roomId, String? eventId, int? notifId) async {
  try {
    final client = await ref
        .read(matrixClientProvider.future)
        .timeout(const Duration(seconds: 5));
    var room = client.getRoomById(roomId);
    if (room == null) {
      debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
      return;
    }

    // The event might not have synced yet — wait for the next sync (max 12 s).
    // We use lastEvent as a proxy: after sync it will be the event we want.
    Event? event = room.lastEvent;
    final bool alreadyHave =
        eventId == null || event?.eventId == eventId;
    if (!alreadyHave || event == null) {
      debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
      await client.onSync.stream.first.timeout(const Duration(seconds: 12));
      room = client.getRoomById(roomId);
      if (room == null) return;
      event = room.lastEvent;
    }

    if (event == null) {
      debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
      return;
    }
    if (event.type != EventTypes.Message) return;
    if (event.senderId == client.userID) return;

    final rawBody = event.body;
    // If decryption failed the body starts with "** Unable to decrypt"
    if (rawBody.isEmpty || rawBody.startsWith('**')) {
      debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
      return;
    }

    final notifBody =
        rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
    final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
    final displayName = sender.displayName ??
        event.senderId.split(':').first.replaceFirst('@', '');
    final title = room.isDirectChat
        ? displayName
        : '$displayName · ${room.getLocalizedDisplayname()}';

    // Overwrite the "Neue Nachricht" placeholder — same notifId so Android
    // replaces the existing notification rather than stacking a new one.
    await _kInstallChannel.invokeMethod('showNativeNotification', {
      'title':   title,
      'body':    notifBody,
      'roomId':  roomId,
      'notifId': notifId ?? _stableRoomId(roomId),
    });
    debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
  } catch (e) {
    debugPrint('[NOTIF] decryptAndUpdate error: $e');
  }
}

// ─── Show notification ────────────────────────────────────────────────────────

Future<void> showMessageNotification({
  required String roomId,
  String? roomName,
  required String senderName,
  required String body,
}) async {
  final title = roomName != null ? '$senderName · $roomName' : senderName;
  if (Platform.isAndroid && _androidInitialized) {
    // Use native Kotlin notification so the reply PendingIntent targets
    // ReplyReceiver directly — the app never opens when the user replies.
    try {
      await _kInstallChannel.invokeMethod('showNativeNotification', {
        'title':   title,
        'body':    body,
        'roomId':  roomId,
        'notifId': _stableRoomId(roomId),
      });
    } catch (e) {
      debugPrint('[NOTIF] showNativeNotification failed: $e');
    }
  } else if (Platform.isWindows) {
    // Close the previous notification for this room before showing a new one.
    _windowsNotifs.remove(roomId)?.close();

    final notif = LocalNotification(
      identifier: 'msg_$roomId',
      title: title,
      body: body,
    );
    notif.onShow = () {};
    _windowsNotifs[roomId] = notif;
    await localNotifier.notify(notif);

    // Also flash the taskbar button
    WindowsTaskbar.setFlashTaskbarAppIcon();
  }
}

// ─── Taskbar badge (Windows) ──────────────────────────────────────────────────

bool _badgeVisible = false;

Future<void> _updateTaskbar(int unreadCount) async {
  if (!Platform.isWindows) return;
  try {
    if (unreadCount > 0 && !_badgeVisible) {
      _badgeVisible = true;
      await WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
    } else if (unreadCount == 0 && _badgeVisible) {
      _badgeVisible = false;
      await WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
    }
  } catch (_) {}
}

// Exported so AppShell can call it when the app resumes from background.
Future<void> checkPendingReply(WidgetRef ref) => _sendPendingReply(ref);

// ─── Watcher provider ─────────────────────────────────────────────────────────

final notificationWatcherProvider = Provider<void>((ref) {
  ref.watch(matrixClientProvider).whenData((client) {
    _watchEvents(ref, client);
    _watchBadge(client);
  });
});

// Set once on first _watchEvents call so we can skip catchup events from the
// initial SDK sync (they were already shown by the FCM background handler).
DateTime? _watchStartTime;

void _watchEvents(Ref ref, Client client) {
  _watchStartTime ??= DateTime.now();
  client.onNotification.stream.listen((Event event) {
    if (event.type != EventTypes.Message) return;
    if (event.senderId == client.userID) return;

    // Skip events that predate this app session — the FCM background handler
    // already showed a notification for them while the app was killed.
    if (event.originServerTs.isBefore(_watchStartTime!)) return;

    // ── Master notification toggle (desktop/Windows setting) ──────────────
    if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
      if (!ref.read(notifDesktopProvider)) return;
    }

    final roomId = event.room.id;
    // Suppress only when the window is focused AND the room is currently open.
    // If the window is minimized or in the background, always show the notification.
    final windowFocused = ref.read(windowFocusedProvider);
    if (windowFocused && roomId == ref.read(activeRoomIdProvider)) return;

    final room = event.room;
    if (room.membership != Membership.join) return;

    // ── Mention-only filter ───────────────────────────────────────────────
    if (ref.read(notifMentionOnlyDmProvider)) {
      final myId = client.userID ?? '';
      // Check MSC3952 m.mentions field first, then fall back to body scan.
      final mentionedIds = (event.content
              .tryGetMap<String, dynamic>('m.mentions')
              ?.tryGetList<String>('user_ids')) ??
          [];
      final bodyMentionsUs = event.body.contains(myId) ||
          event.body.contains('@${myId.split(':').first.replaceFirst('@', '')}');
      if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
    }

    // ── Build notification body ───────────────────────────────────────────
    final rawBody = event.body;
    final showPreview = ref.read(notifPreviewProvider);
    final notifBody = showPreview
        ? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
        : 'Neue Nachricht';

    final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
    final displayName = sender.displayName ??
        event.senderId.split(':').first.replaceFirst('@', '');

    showMessageNotification(
      roomId: roomId,
      roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
      senderName: displayName,
      body: notifBody,
    );
  });
}

void _watchBadge(Client client) {
  client.onSync.stream.listen(
    (_) {
      try {
        final total = client.rooms
            .where((r) => r.membership == Membership.join)
            .fold<int>(0, (sum, r) => sum + r.notificationCount);
        _updateTaskbar(total);
        _pingHeartbeat(); // keep PushService.kt from double-notifying
      } catch (_) {}
    },
    onError: (_) {},
    cancelOnError: false,
  );
}
