import 'dart:async';
import 'dart:convert';
import 'dart:developer';

import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

import '../notification/notification_service.dart';
import '../theming/light_app_theme.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_actions.dart';
import 'push_avatar.dart';
import 'push_subject.dart';

/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE
/// [MessagingStyleInformation] notification (stable id/tag per chat, history
/// from [ChatThreadStore]) with inline reply + mark-as-read actions;
/// everything else renders in a generic channel.
class PushRenderer {
  static const talkChannelId = 'talk_messages';
  static const talkChannelName = 'Talk-Nachrichten';
  static const generalChannelId = 'nextcloud_general';
  static const generalChannelName = 'Benachrichtigungen';

  static const String iosTalkCategory = 'TALK_MESSAGE';

  /// Brand accent: colors the (monochrome) small icon and action buttons in
  /// the notification shade instead of the default grey.
  static const _accentColor = LightAppTheme.marianumRed;

  final NidStore _nidStore;
  final PushAvatarStore _avatarStore;
  final ChatThreadStore _threadStore;

  PushRenderer({
    NidStore? nidStore,
    PushAvatarStore? avatarStore,
    ChatThreadStore? threadStore,
  }) : _nidStore = nidStore ?? NidStore(),
       _avatarStore = avatarStore ?? PushAvatarStore(),
       _threadStore = threadStore ?? ChatThreadStore();

  FlutterLocalNotificationsPlugin get _plugin =>
      NotificationService().flutterLocalNotificationsPlugin;

  /// Creates the Android notification channels. Safe to call repeatedly.
  static Future<void> ensureChannels() async {
    final android = NotificationService().flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin
        >();
    if (android == null) return;
    await android.createNotificationChannel(
      const AndroidNotificationChannel(
        talkChannelId,
        talkChannelName,
        description: 'Neue Nachrichten aus Nextcloud Talk',
        importance: Importance.high,
      ),
    );
    await android.createNotificationChannel(
      const AndroidNotificationChannel(
        generalChannelId,
        generalChannelName,
        description: 'Allgemeine Benachrichtigungen',
      ),
    );
  }

  /// Renders a decrypted Nextcloud push subject.
  Future<void> render(PushSubject subject) async {
    if (subject.isTalk) {
      await _renderTalk(subject);
    } else {
      await _renderGeneric(subject);
    }
  }

  /// Tri-state probe of whether this chat's stacked notification is still in
  /// the tray. `null` when the query fails or isn't supported (native code not
  /// linked, older platform) — the caller then keeps stacking defensively. A
  /// successful query is authoritative: absence means dismissed/read.
  /// Works in the background isolate (pure plugin call, no BuildContext).
  Future<bool?> _isChatNotificationActive(String chatToken) async {
    final id = stableChatNotificationId(chatToken);
    final tag = chatNotificationTag(chatToken);
    try {
      final actives = await _plugin.getActiveNotifications();
      return actives.any((n) => n.id == id || n.tag == tag);
    } on Object catch (e) {
      log('Push: getActiveNotifications probe failed: $e');
      return null;
    }
  }

  Future<void> _renderTalk(PushSubject subject) async {
    final chatToken = subject.id;
    final parsed = parseTalkSubject(subject.subject ?? 'Neue Nachricht');
    final nid =
        subject.nid ??
        _fallbackId('${chatToken ?? ''}${parsed.sender}${parsed.text}');

    if (chatToken == null || chatToken.isEmpty) {
      // Without a chat token there is nothing to stack under — render a
      // standalone card keyed by the nid.
      await _renderTalkStandalone(nid, parsed.sender, parsed.text);
      return;
    }

    // Restart the thread when the previous notification is gone (swiped away
    // or the chat was read without our cleanup running) — otherwise the new
    // push would resurrect the already-seen history. A failed/unsupported
    // probe keeps stacking (see threadAfterIncoming).
    final isActive = await _isChatNotificationActive(chatToken);
    final messages = await _threadStore.appendConsideringActive(
      chatToken,
      ThreadMessage(
        nid: nid,
        sender: parsed.sender,
        text: parsed.text,
        timestampMs: DateTime.now().millisecondsSinceEpoch,
        roomName: parsed.roomName,
      ),
      isActive,
    );
    await _nidStore.put(
      NidEntry(
        nid: nid,
        notificationId: stableChatNotificationId(chatToken),
        tag: chatNotificationTag(chatToken),
        chatToken: chatToken,
      ),
    );
    await renderTalkThread(chatToken, messages);
  }

  /// Shows/updates the ONE stacked notification of [chatToken] from the given
  /// history. Identity is stable per chat ([stableChatNotificationId] +
  /// [chatNotificationTag]) so each new message updates the same card.
  /// [alert] false re-renders silently — used when a delete-push removed one
  /// of several messages and the remaining thread must not ping again.
  Future<void> renderTalkThread(
    String chatToken,
    List<ThreadMessage> messages, {
    bool alert = true,
  }) async {
    if (messages.isEmpty) return;
    // A silent render only UPDATES an existing card. If the card is verifiably
    // gone (cleanup cancelled it or the user swiped it away), re-posting would
    // resurrect it — with Android re-attaching a pending inline reply on top.
    // Probe failure (null) still renders: stopping a possible reply spinner
    // outweighs a rare resurrection.
    if (!alert && await _isChatNotificationActive(chatToken) == false) {
      debugPrint('PushRenderer: skip silent re-render, card gone ($chatToken)');
      return;
    }
    final tag = chatNotificationTag(chatToken);
    final id = stableChatNotificationId(chatToken);
    final latest = messages.last;

    // Conversation avatar (the person's picture in 1:1 chats), pre-masked
    // round. Bounded by the store's fetch timeout; when the icon isn't ready
    // in time the notification renders without it and a still-running fetch
    // triggers ONE silent re-render below once bytes arrive.
    final lookup = await _avatarStore.roomAvatarIcon(chatToken);
    final avatarBytes = lookup.icon;
    if (alert && avatarBytes == null && lookup.late != null) {
      unawaited(
        lookup.late!.then((bytes) async {
          if (bytes == null) return;
          final fresh = await _threadStore.messages(chatToken);
          if (fresh.isEmpty) return;
          // Re-render hits the processed cache → icon present, no re-schedule.
          await renderTalkThread(chatToken, fresh, alert: false);
        }),
      );
    }
    final senders = <String, Person>{};
    Person personFor(String sender) => senders.putIfAbsent(
      sender,
      () => Person(
        key: sender,
        name: sender,
        icon: avatarBytes == null ? null : ByteArrayAndroidIcon(avatarBytes),
      ),
    );

    final styleMessages = [
      for (final m in messages)
        Message(
          m.text,
          DateTime.fromMillisecondsSinceEpoch(m.timestampMs),
          personFor(m.sender),
        ),
    ];
    final header = conversationHeader(messages);
    final messagingStyle = MessagingStyleInformation(
      const Person(key: 'self', name: 'Ich'),
      conversationTitle: header.conversationTitle,
      groupConversation: header.groupConversation,
      messages: styleMessages,
    );

    final androidDetails = AndroidNotificationDetails(
      talkChannelId,
      talkChannelName,
      importance: Importance.high,
      priority: Priority.high,
      category: AndroidNotificationCategory.message,
      color: _accentColor,
      tag: tag,
      silent: !alert,
      styleInformation: messagingStyle,
      actions: _talkActions,
    );

    final iosDetails = DarwinNotificationDetails(
      threadIdentifier: tag,
      categoryIdentifier: iosTalkCategory,
      presentSound: alert ? null : false,
      presentBanner: alert ? null : false,
    );

    await _plugin.show(
      id: id,
      title: latest.sender,
      body: latest.text,
      notificationDetails: NotificationDetails(
        android: androidDetails,
        iOS: iosDetails,
      ),
      payload: _payload(chatToken: chatToken, nid: latest.nid),
    );
  }

  Future<void> _renderTalkStandalone(
    int nid,
    String senderName,
    String messageText,
  ) async {
    final tag = 'talk_$nid';
    await _nidStore.put(NidEntry(nid: nid, notificationId: nid, tag: tag));
    await _plugin.show(
      id: nid,
      title: senderName,
      body: messageText,
      notificationDetails: NotificationDetails(
        android: AndroidNotificationDetails(
          talkChannelId,
          talkChannelName,
          importance: Importance.high,
          priority: Priority.high,
          category: AndroidNotificationCategory.message,
          color: _accentColor,
          tag: tag,
          // Single sender ⇒ no conversationTitle (1:1 convention): the sender
          // Person is the header, a title would repeat the name per line.
          styleInformation: MessagingStyleInformation(
            const Person(key: 'self', name: 'Ich'),
            groupConversation: false,
            messages: [
              Message(messageText, DateTime.now(), Person(name: senderName)),
            ],
          ),
        ),
        iOS: const DarwinNotificationDetails(),
      ),
      payload: _payload(chatToken: null, nid: nid),
    );
  }

  // Both actions keep cancelNotification: true (the default): the plugin's
  // Java receiver then removes the card NATIVELY the moment the action fires.
  // The reply action must not rely on our Dart-side cancel instead — MIUI/
  // HyperOS ignores an app-issued cancel while an inline reply is pending, so
  // the card would stay behind showing the reply as an attached "Ich" row.
  // If sending subsequently fails, the error card (with the typed text)
  // replaces the lost thread card.
  static const _talkActions = [
    AndroidNotificationAction(
      kTalkReplyActionId,
      'Antworten',
      showsUserInterface: false,
      inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
    ),
    AndroidNotificationAction(
      kTalkMarkReadActionId,
      'Gelesen',
      showsUserInterface: false,
    ),
  ];

  /// Renders a failure card for a Talk notification action (reply/mark-read).
  /// Separate tag per chat, so it neither replaces the thread notification
  /// nor stacks across repeated failures. Carries no payload — tapping it
  /// just opens the app.
  Future<void> renderTalkActionError({
    required String chatToken,
    required String title,
    required String body,
  }) async {
    await _plugin.show(
      id: stableChatNotificationId(chatToken),
      title: title,
      body: body,
      notificationDetails: NotificationDetails(
        android: AndroidNotificationDetails(
          talkChannelId,
          talkChannelName,
          importance: Importance.high,
          priority: Priority.high,
          color: _accentColor,
          tag: 'talk_error_$chatToken',
          styleInformation: BigTextStyleInformation(body),
        ),
        iOS: const DarwinNotificationDetails(),
      ),
    );
  }

  Future<void> _renderGeneric(PushSubject subject) async {
    final nid = subject.nid ?? _fallbackId(subject.subject);
    const androidDetails = AndroidNotificationDetails(
      generalChannelId,
      generalChannelName,
      color: _accentColor,
    );
    const iosDetails = DarwinNotificationDetails();
    await _nidStore.put(
      NidEntry(nid: nid, notificationId: nid, tag: 'nc_$nid'),
    );
    await _plugin.show(
      id: nid,
      title: subject.subject ?? 'Neue Benachrichtigung',
      body: null,
      notificationDetails: const NotificationDetails(
        android: androidDetails,
        iOS: iosDetails,
      ),
      payload: _payload(chatToken: null, nid: nid),
    );
  }

  /// Renders a plaintext MarianumConnect direct push (Android only — iOS shows
  /// the native alert itself).
  Future<void> renderConnect({
    required String title,
    required String body,
    Map<String, String>? data,
  }) async {
    final id = _fallbackId('$title$body');
    const androidDetails = AndroidNotificationDetails(
      generalChannelId,
      generalChannelName,
      importance: Importance.high,
      priority: Priority.high,
      color: _accentColor,
    );
    await _plugin.show(
      id: id,
      title: title,
      body: body,
      notificationDetails: const NotificationDetails(android: androidDetails),
      payload: data == null ? null : jsonEncode(data),
    );
  }

  String _payload({required String? chatToken, required int nid}) =>
      jsonEncode({'chatToken': ?chatToken, 'nid': nid});

  /// Deterministic non-negative 31-bit id from a string, used when the push
  /// carries no `nid`. Shares the hash with [stableChatNotificationId] (an
  /// empty/null seed hashes to 0).
  int _fallbackId(String? seed) => stableChatNotificationId(seed ?? '');
}
