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

import 'package:flutter/foundation.dart';

import '../../core/config/komet_settings.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/cache/info_cache.dart';
import '../../core/cache/message_session_cache.dart';
import 'shared_content.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/text_format.dart';
import '../../models/contact_info.dart';
import '../api.dart';
import 'chat_parsing.dart';
import 'chat_preview.dart';
import 'folders.dart';
import 'messages.dart' show ContactCache, CachedMessage;

Map<int, int> parseParticipants(dynamic raw) {
  try {
    final decoded = raw is String ? jsonDecode(raw) : raw;
    if (decoded is Map) {
      return decoded.map(
        (k, v) => MapEntry(
          k is int ? k : int.parse(k.toString()),
          v is int ? v : int.tryParse(v.toString()) ?? 0,
        ),
      );
    }
  } catch (e) {
    logger.e('Failed to parse participants: $e');
  }
  return {};
}

/// Server message ids carry their timestamp in the high bits: the low 16 bits
/// are an intra-millisecond sequence number.
int messageIdToTime(int messageId) => messageId >> 16;

bool messageMentionsUser(Map<dynamic, dynamic> message, int userId) {
  final elements = message['elements'];
  if (elements is! List) return false;
  for (final element in elements.whereType<Map>()) {
    if (element['type']?.toString() != 'USER_MENTION') continue;
    if (element['entityId'] == userId) return true;
  }
  return false;
}

class CachedChat {
  final int id;
  final int accountId;
  final String type;
  final String? title;
  final String? iconUrl;
  final int? lastMsgId;
  final int? lastMsgTime;
  final String? lastMsgText;
  final String? lastMsgTextOneLine;
  final String? lastMsgElements;
  final int? lastMsgSenderId;
  final String? lastMsgStatus;
  final int unreadCount;
  final int lastEventTime;
  final int cachedAt;
  final int? favIndex;
  final int dontDisturbUntil;
  final bool isOnline;
  final int seenTime;
  final Map<int, int> participants;
  final Set<String> options;
  final int? owner;
  final Set<int> admins;
  final int? pinnedMsgId;
  final String? pinnedMsgText;
  final int? pinnedMsgTime;
  final bool pinnedMsgIsPreview;
  final int? lastMentionMsgId;

  CachedChat({
    required this.id,
    required this.accountId,
    required this.type,
    this.title,
    this.iconUrl,
    this.lastMsgId,
    this.lastMsgTime,
    this.lastMsgText,
    this.lastMsgElements,
    this.lastMsgSenderId,
    this.lastMsgStatus,
    required this.unreadCount,
    required this.lastEventTime,
    required this.cachedAt,
    this.favIndex,
    required this.dontDisturbUntil,
    required this.isOnline,
    required this.seenTime,
    required this.participants,
    this.options = const {},
    this.owner,
    this.admins = const {},
    this.pinnedMsgId,
    this.pinnedMsgText,
    this.pinnedMsgTime,
    this.pinnedMsgIsPreview = false,
    this.lastMentionMsgId,
  }) : lastMsgTextOneLine = lastMsgText != null && lastMsgText.contains('\n')
           ? lastMsgText.replaceAll('\n', ' ')
           : lastMsgText;

  bool get isOfficial => options.contains('OFFICIAL');

  List<FormatRange> get lastMsgFormatRanges {
    final raw = lastMsgElements;
    if (raw == null || raw.isEmpty) return const [];
    try {
      return parseFormatElements(jsonDecode(raw));
    } catch (_) {
      return const [];
    }
  }

  bool get lastMsgReadByOthers {
    final t = lastMsgTime;
    if (t == null) return false;
    for (final entry in participants.entries) {
      if (entry.key != accountId && entry.value >= t) return true;
    }
    return false;
  }

  bool iAmAdmin(int myId) => owner == myId || admins.contains(myId);

  bool get hasPinnedMessage => pinnedMsgId != null;

  bool get isGroupChat => type == 'CHAT' || type == 'GROUP';

  bool canPinMessages(int myId) {
    if (!isGroupChat) return false;
    return iAmAdmin(myId) || options.contains('ALL_CAN_PIN_MESSAGE');
  }

  bool get isMuted {
    if (dontDisturbUntil == ChatsModule.muteOff) return false;
    if (dontDisturbUntil < 0) return true;
    return dontDisturbUntil > DateTime.now().millisecondsSinceEpoch;
  }

  bool get isLastMsgDeleted => lastMsgText == ChatsModule.lastMsgPlaceholder;

  bool get hasUnreadMention {
    final mentionId = lastMentionMsgId;
    if (mentionId == null || mentionId <= 0) return false;
    return messageIdToTime(mentionId) > (participants[accountId] ?? 0);
  }

  factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
    id: row['id'] as int,
    accountId: row['account_id'] as int,
    type: row['type'] as String,
    title: row['title'] as String?,
    iconUrl: row['icon_url'] as String?,
    lastMsgId: row['last_msg_id'] as int?,
    lastMsgTime: row['last_msg_time'] as int?,
    lastMsgText: row['last_msg_text'] as String?,
    lastMsgElements: row['last_msg_elements'] as String?,
    lastMsgSenderId: row['last_msg_sender'] as int?,
    lastMsgStatus: row['last_msg_status'] as String?,
    unreadCount: row['unread_count'] as int,
    lastEventTime: row['last_event_time'] as int,
    cachedAt: row['cached_at'] as int,
    favIndex: row['fav_index'] as int?,
    dontDisturbUntil: row['dont_disturb_until'] as int,
    isOnline: (row['is_online'] as int) == 1,
    seenTime: row['seen_time'] as int,
    participants: parseParticipants(row['participants']),
    options: _decodeOptions(row['options']),
    owner: row['owner'] as int?,
    admins: _decodeAdmins(row['admins']),
    pinnedMsgId: row['pinned_msg_id'] as int?,
    pinnedMsgText: row['pinned_msg_text'] as String?,
    pinnedMsgTime: row['pinned_msg_time'] as int?,
    pinnedMsgIsPreview: (row['pinned_msg_is_preview'] as int? ?? 0) == 1,
    lastMentionMsgId: row['last_mention_msg_id'] as int?,
  );

  static Set<String> _decodeOptions(dynamic raw) {
    if (raw is! String || raw.isEmpty) return const {};
    return raw.split(',').where((s) => s.isNotEmpty).toSet();
  }

  static Set<int> _decodeAdmins(dynamic raw) {
    if (raw is! String || raw.isEmpty) return const {};
    return raw
        .split(',')
        .map((s) => int.tryParse(s.trim()))
        .whereType<int>()
        .toSet();
  }

  Map<String, dynamic> toDbRow() => {
    'id': id,
    'account_id': accountId,
    'type': type,
    'title': title,
    'icon_url': iconUrl,
    'last_msg_id': lastMsgId,
    'last_msg_time': lastMsgTime,
    'last_msg_text': lastMsgText,
    'last_msg_elements': lastMsgElements,
    'last_msg_sender': lastMsgSenderId,
    'last_msg_status': lastMsgStatus,
    'unread_count': unreadCount,
    'last_event_time': lastEventTime,
    'cached_at': cachedAt,
    'fav_index': favIndex,
    'dont_disturb_until': dontDisturbUntil,
    'is_online': isOnline ? 1 : 0,
    'seen_time': seenTime,
    'participants': jsonEncode(
      participants.map((k, v) => MapEntry(k.toString(), v)),
    ),
    'options': options.isEmpty ? null : options.join(','),
    'owner': owner,
    'admins': admins.isEmpty ? null : admins.join(','),
    'pinned_msg_id': pinnedMsgId,
    'pinned_msg_text': pinnedMsgText,
    'pinned_msg_time': pinnedMsgTime,
    'pinned_msg_is_preview': pinnedMsgIsPreview ? 1 : 0,
    'last_mention_msg_id': lastMentionMsgId,
  };

  static const Object _keep = Object();

  CachedChat copyWith({
    String? type,
    Object? title = _keep,
    Object? iconUrl = _keep,
    Object? lastMsgId = _keep,
    Object? lastMsgTime = _keep,
    Object? lastMsgText = _keep,
    Object? lastMsgElements = _keep,
    Object? lastMsgSenderId = _keep,
    Object? lastMsgStatus = _keep,
    int? unreadCount,
    int? lastEventTime,
    int? cachedAt,
    Object? favIndex = _keep,
    int? dontDisturbUntil,
    bool? isOnline,
    int? seenTime,
    Map<int, int>? participants,
    Set<String>? options,
    Object? owner = _keep,
    Set<int>? admins,
    Object? pinnedMsgId = _keep,
    Object? pinnedMsgText = _keep,
    Object? pinnedMsgTime = _keep,
    bool? pinnedMsgIsPreview,
    Object? lastMentionMsgId = _keep,
  }) {
    return CachedChat(
      id: id,
      accountId: accountId,
      type: type ?? this.type,
      title: identical(title, _keep) ? this.title : title as String?,
      iconUrl: identical(iconUrl, _keep) ? this.iconUrl : iconUrl as String?,
      lastMsgId: identical(lastMsgId, _keep)
          ? this.lastMsgId
          : lastMsgId as int?,
      lastMsgTime: identical(lastMsgTime, _keep)
          ? this.lastMsgTime
          : lastMsgTime as int?,
      lastMsgText: identical(lastMsgText, _keep)
          ? this.lastMsgText
          : lastMsgText as String?,
      lastMsgElements: identical(lastMsgElements, _keep)
          ? this.lastMsgElements
          : lastMsgElements as String?,
      lastMsgSenderId: identical(lastMsgSenderId, _keep)
          ? this.lastMsgSenderId
          : lastMsgSenderId as int?,
      lastMsgStatus: identical(lastMsgStatus, _keep)
          ? this.lastMsgStatus
          : lastMsgStatus as String?,
      unreadCount: unreadCount ?? this.unreadCount,
      lastEventTime: lastEventTime ?? this.lastEventTime,
      cachedAt: cachedAt ?? this.cachedAt,
      favIndex: identical(favIndex, _keep) ? this.favIndex : favIndex as int?,
      dontDisturbUntil: dontDisturbUntil ?? this.dontDisturbUntil,
      isOnline: isOnline ?? this.isOnline,
      seenTime: seenTime ?? this.seenTime,
      participants: participants ?? this.participants,
      options: options ?? this.options,
      owner: identical(owner, _keep) ? this.owner : owner as int?,
      admins: admins ?? this.admins,
      pinnedMsgId: identical(pinnedMsgId, _keep)
          ? this.pinnedMsgId
          : pinnedMsgId as int?,
      pinnedMsgText: identical(pinnedMsgText, _keep)
          ? this.pinnedMsgText
          : pinnedMsgText as String?,
      pinnedMsgTime: identical(pinnedMsgTime, _keep)
          ? this.pinnedMsgTime
          : pinnedMsgTime as int?,
      pinnedMsgIsPreview: pinnedMsgIsPreview ?? this.pinnedMsgIsPreview,
      lastMentionMsgId: identical(lastMentionMsgId, _keep)
          ? this.lastMentionMsgId
          : lastMentionMsgId as int?,
    );
  }
}

class ChatSearchHit {
  final int id;
  final String type;
  final String? title;
  final String? avatarUrl;
  final String? subtitle;

  const ChatSearchHit({
    required this.id,
    required this.type,
    this.title,
    this.avatarUrl,
    this.subtitle,
  });
}

class ChatMemberEntry {
  final int id;
  final String? name;
  final String? fullName;
  final String? avatarUrl;
  final int? seenTime;
  final int presenceStatus;
  final bool blocked;
  final bool isContact;

  const ChatMemberEntry({
    required this.id,
    this.name,
    this.fullName,
    this.avatarUrl,
    this.seenTime,
    required this.presenceStatus,
    this.blocked = false,
    this.isContact = false,
  });

  bool get isOnline => presenceStatus == 1;
}

class ChatMembersPage {
  final List<ChatMemberEntry> members;
  final int marker;

  const ChatMembersPage({required this.members, required this.marker});
}

class MessageSearchHit {
  final int chatId;
  final String? messageId;
  final String? text;
  final int time;
  final int senderId;

  const MessageSearchHit({
    required this.chatId,
    this.messageId,
    this.text,
    required this.time,
    required this.senderId,
  });
}

sealed class MessageEvent {
  final int chatId;
  const MessageEvent(this.chatId);
}

class MessageAddedEvent extends MessageEvent {
  final CachedMessage message;
  const MessageAddedEvent(super.chatId, this.message);
}

class MessageEditedEvent extends MessageEvent {
  final CachedMessage message;
  const MessageEditedEvent(super.chatId, this.message);
}

class MessageRemovedEvent extends MessageEvent {
  final String messageId;
  const MessageRemovedEvent(super.chatId, this.messageId);
}

class MessageMarkedDeletedEvent extends MessageEvent {
  final String messageId;
  const MessageMarkedDeletedEvent(super.chatId, this.messageId);
}

class MessageReactionsChangedEvent extends MessageEvent {
  final String messageId;
  final Map<String, dynamic>? reactionInfo;
  const MessageReactionsChangedEvent(
    super.chatId,
    this.messageId,
    this.reactionInfo,
  );
}

class MessageSentEvent extends MessageEvent {
  final String tempId;
  final CachedMessage message;
  const MessageSentEvent(super.chatId, this.tempId, this.message);
}

class ChatsModule {
  static const int muteOff = 0;
  static const int muteForever = -1;

  /// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено,
  /// а кеша истории нет — UI должен отрисовать курсивную плашку.
  static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__';

  ChatsModule._();

  final _messageEventsController = StreamController<MessageEvent>.broadcast();
  Stream<MessageEvent> get messageEvents => _messageEventsController.stream;

  int? _paginatedAccountId;

  void emitMessageSent(int chatId, String tempId, CachedMessage message) {
    _messageEventsController.add(MessageSentEvent(chatId, tempId, message));
  }

  Future<void> markRead(
    Api api,
    int accountId,
    int chatId,
    String messageId,
    int mark,
  ) async {
    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) return;
    final row = Map<String, dynamic>.from(rows.first);
    if ((row['unread_count'] as int? ?? 0) == 0) return;

    final msgIdNum = int.tryParse(messageId);
    if (msgIdNum != null && !KometSettings.antiRead.value) {
      try {
        await api.sendRequest(Opcode.chatMark, {
          'type': 'READ_MESSAGE',
          'chatId': chatId,
          'messageId': msgIdNum,
          'mark': mark,
        }, silent: true);
      } catch (_) {}
    }

    final cached = CachedChat.fromDbRow(row);
    final currentMark = cached.participants[accountId] ?? 0;
    final participants = Map<int, int>.from(cached.participants)
      ..[accountId] = mark > currentMark ? mark : currentMark;
    final updated = cached.copyWith(unreadCount: 0, participants: participants);
    await AppDatabase.saveChats([updated.toDbRow()]);
    _bump();
  }

  Future<void> markReadUpTo(
    Api api,
    int accountId,
    int chatId,
    String messageId,
    int mark, {
    required int remaining,
  }) async {
    final msgIdNum = int.tryParse(messageId);
    if (msgIdNum != null && !KometSettings.antiRead.value) {
      try {
        await api.sendRequest(Opcode.chatMark, {
          'type': 'READ_MESSAGE',
          'chatId': chatId,
          'messageId': msgIdNum,
          'mark': mark,
        }, silent: true);
      } catch (_) {}
    }

    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) return;
    final cached = CachedChat.fromDbRow(rows.first);
    final next = remaining < 0 ? 0 : remaining;
    final currentMark = cached.participants[accountId] ?? 0;
    final nextMark = mark > currentMark ? mark : currentMark;
    if (cached.unreadCount == next && nextMark == currentMark) return;
    final participants = Map<int, int>.from(cached.participants)
      ..[accountId] = nextMark;
    final updated = cached.copyWith(unreadCount: next, participants: participants);
    await AppDatabase.saveChats([updated.toDbRow()]);
    _bump();
  }

  Future<int?> markUnread(Api api, int accountId, int chatId, int mark) async {
    int? unread;
    try {
      final resp = await api.sendRequest(Opcode.chatMark, {
        'type': 'SET_AS_UNREAD',
        'chatId': chatId,
        'mark': mark,
      });
      final payload = resp.payload;
      if (payload is Map) unread = payload['unread'] as int?;
    } catch (_) {
      return null;
    }
    if (unread == null) return null;

    await _updateChat(accountId, chatId, (chat) {
      final participants = Map<int, int>.from(chat.participants)
        ..[accountId] = mark - 1;
      return chat.copyWith(unreadCount: unread, participants: participants);
    });
    return unread;
  }

  Future<void> applyOutgoing(
    int accountId,
    int chatId, {
    required String messageId,
    required int time,
    required String text,
    required String status,
    List<Map<String, dynamic>>? elements,
  }) async {
    final thisId = int.tryParse(messageId);
    await _updateChat(accountId, chatId, (chat) {
      final existingTime = chat.lastMsgTime ?? 0;
      if (time < existingTime && chat.lastMsgId != thisId) return null;
      return chat.copyWith(
        lastMsgId: thisId,
        lastMsgText: text,
        lastMsgElements: (elements != null && elements.isNotEmpty)
            ? jsonEncode(elements)
            : null,
        lastMsgTime: time,
        lastEventTime: time,
        lastMsgSenderId: accountId,
        lastMsgStatus: status,
      );
    });
  }

  final ValueNotifier<int> chatsChanged = ValueNotifier(0);
  void _bump() => chatsChanged.value = chatsChanged.value + 1;

  Future<bool> _updateChat(
    int accountId,
    int chatId,
    CachedChat? Function(CachedChat chat) mutate,
  ) async {
    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) return false;
    final updated = mutate(CachedChat.fromDbRow(rows.first));
    if (updated == null) return false;
    final row = Map<String, dynamic>.from(rows.first)
      ..addAll(updated.toDbRow());
    await AppDatabase.saveChats([row]);
    _bump();
    return true;
  }

  static Map<String, dynamic>? _decodePayload(dynamic raw) {
    if (raw is String && raw.isNotEmpty) {
      try {
        return Map<String, dynamic>.from(jsonDecode(raw) as Map);
      } catch (_) {}
    }
    return null;
  }

  StreamSubscription<Packet>? _globalPushSub;
  StreamSubscription<SessionState>? _globalStateSub;
  Future<void> _pushQueue = Future.value();

  final Set<int> _historyFetched = {};

  bool wasHistoryFetched(int chatId) => _historyFetched.contains(chatId);
  void markHistoryFetched(int chatId) => _historyFetched.add(chatId);

  void attachGlobalPushHandlers(Api api) {
    _globalPushSub?.cancel();
    _globalStateSub?.cancel();
    _globalPushSub = api.pushStream.listen(_enqueueGlobalPush);
    _globalStateSub = api.stateStream.listen(_handleSessionState);
  }

  void dispose() {
    _globalPushSub?.cancel();
    _globalStateSub?.cancel();
    _globalPushSub = null;
    _globalStateSub = null;
    _contactFlushTimer?.cancel();
    _contactFlushTimer = null;
    _messageEventsController.close();
    chatsChanged.dispose();
  }

  void _handleSessionState(SessionState state) {
    if (state == SessionState.disconnected) {
      ContactInfoFetch.clear();
      PresenceFetch.clear();
      ChatInfoFetch.clear();
      _historyFetched.clear();
    }
  }

  void resetForAccountSwitch() {
    _historyFetched.clear();
    ContactInfoFetch.clear();
    PresenceFetch.clear();
    ChatInfoFetch.clear();
    SharedContentModule.clearMediaIndex();
  }

  void _enqueueGlobalPush(Packet packet) {
    _pushQueue = _pushQueue.then((_) => _handleGlobalPush(packet)).catchError((
      Object e,
    ) {
      logger.w('Ошибка обработки пуша: $e');
    });
  }

  Future<void> _handleGlobalPush(Packet packet) async {
    switch (packet.opcode) {
      case Opcode.notifMessage:
        await _handleNotifMessage(packet);
      case Opcode.notifMark:
        await _handleNotifMark(packet);
      case Opcode.notifMsgReactionsChanged:
        await _handleNotifMsgReactionsChanged(packet);
      case Opcode.notifMsgDelete:
        await _handleNotifMsgDelete(packet);
      case Opcode.notifPresence:
        _handlePresence(packet);
    }
  }

  void _handlePresence(Packet packet) {
    final payload = packet.payload;
    if (payload is! Map) return;
    final userId = payload['userId'];
    if (userId is! int) return;
    final presence = payload['presence'];
    if (presence is! Map) return;
    PresenceFetch.apply(userId, Map<String, dynamic>.from(presence));
  }

  Future<void> _handleNotifMsgDelete(Packet packet) async {
    final payload = packet.payload;
    if (payload is! Map) return;
    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) return;

    final chatMap = payload['chat'];
    int? chatId;
    if (chatMap is Map && chatMap['id'] is int) {
      chatId = chatMap['id'] as int;
      await cacheServerChat(chatMap.cast<dynamic, dynamic>(), accountId);
    } else if (payload['chatId'] is int) {
      chatId = payload['chatId'] as int;
    }
    if (chatId == null) return;

    final keepDeleted = KometSettings.viewDeleted.value;
    final ids = payload['messageIds'];
    if (ids is List) {
      for (final raw in ids) {
        final id = raw?.toString();
        if (id == null || id.isEmpty) continue;
        if (keepDeleted) {
          await AppDatabase.markMessageDeleted(accountId, chatId, id);
          _messageEventsController.add(MessageMarkedDeletedEvent(chatId, id));
        } else {
          await AppDatabase.deleteMessage(accountId, chatId, id);
          _messageEventsController.add(MessageRemovedEvent(chatId, id));
        }
      }
    }
    _bump();
  }

  Future<void> _handleNotifMessage(Packet packet) async {
    final payload = packet.payload;
    if (payload is! Map) return;
    final chatId = payload['chatId'];
    if (chatId is! int) return;
    final msg = payload['message'];
    if (msg is! Map) return;

    final msgLink = msg['link'];
    final linkPostId = (msgLink is Map) ? msgLink['postId'] : null;
    final payloadPostId = payload['postId'];
    final isCommentPush =
        payloadPostId is String ||
        (linkPostId is String) ||
        (msg['postId'] is String);
    if (isCommentPush) return;

    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) return;

    final senderId = msg['sender'] as int?;
    final msgIdStr = msg['id']?.toString();
    final msgIdInt = (msg['id'] is int)
        ? msg['id'] as int
        : int.tryParse(msgIdStr ?? '');
    final msgTime = msg['time'] as int?;
    final msgText = msg['text'] as String?;
    final status = msg['status'] as String?;
    final unread = payload['unread'] as int?;

    var rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) {
      try {
        final chatInfo = await ChatInfoFetch.get(chatId);
        if (chatInfo != null) {
          await cacheServerChat(chatInfo.raw, accountId);
        }
      } catch (e) {
        logger.w(
          'notifMessage: fetch info for unknown chat $chatId failed: $e',
        );
        return;
      }
      rows = await AppDatabase.loadChat(accountId, chatId);
      if (rows.isEmpty) return;
    }

    if (status == 'REMOVED' && msgIdStr != null) {
      final keepDeleted = KometSettings.viewDeleted.value;
      if (keepDeleted) {
        await AppDatabase.markMessageDeleted(accountId, chatId, msgIdStr);
      } else {
        await AppDatabase.deleteMessage(accountId, chatId, msgIdStr);
      }
      final cachedChat = CachedChat.fromDbRow(rows.first);
      if (cachedChat.lastMsgId == msgIdInt) {
        await _reconcileLastMessage(
          accountId,
          chatId,
          rows.first,
          unread: unread,
        );
      } else if (unread != null) {
        final newRow = Map<String, dynamic>.from(rows.first);
        newRow['unread_count'] = unread;
        await AppDatabase.saveChats([newRow]);
      }
      _messageEventsController.add(
        keepDeleted
            ? MessageMarkedDeletedEvent(chatId, msgIdStr)
            : MessageRemovedEvent(chatId, msgIdStr),
      );
      _bump();
      return;
    }

    CachedMessage? emittedMessage;
    if (status == 'EDITED' && msgIdStr != null) {
      final existing = await AppDatabase.loadMessage(
        accountId,
        chatId,
        msgIdStr,
      );
      if (existing != null) {
        final mergedPayload =
            _decodePayload(existing['payload']) ??
            Map<String, dynamic>.from(msg);
        for (final entry in msg.entries) {
          if (entry.key == 'reactionInfo') continue;
          mergedPayload[entry.key.toString()] = entry.value;
        }
        final newRow = Map<String, dynamic>.from(existing);
        if (KometSettings.viewRedacted.value) {
          final oldText = existing['text']?.toString();
          if ((oldText ?? '') != (msgText ?? '') &&
              oldText != null &&
              oldText.isNotEmpty) {
            final history = CachedMessage.appendEditHistory(
              CachedMessage.parseEditHistory(existing['edit_history']),
              oldText,
              DateTime.now().millisecondsSinceEpoch,
            );
            newRow['edit_history'] = jsonEncode(history);
          }
        }
        newRow['text'] = msgText;
        newRow['status'] = status;
        newRow['payload'] = jsonEncode(mergedPayload);
        await AppDatabase.saveMessages([newRow]);
        emittedMessage = CachedMessage.fromDbRow(newRow);
        _messageEventsController.add(
          MessageEditedEvent(chatId, emittedMessage),
        );
      }
    } else if (msgIdStr != null) {
      final existing = await AppDatabase.loadMessage(
        accountId,
        chatId,
        msgIdStr,
      );
      if (existing == null) {
        final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
        await AppDatabase.saveMessages([cached.toDbRow()]);
        emittedMessage = cached;
        _messageEventsController.add(MessageAddedEvent(chatId, cached));
      }
    }

    final cached = CachedChat.fromDbRow(rows.first);
    final isStaleLast =
        status != 'REMOVED' &&
        msgIdInt != null &&
        cached.lastMsgId == msgIdInt &&
        status != 'EDITED';
    if (isStaleLast) {
      _bump();
      return;
    }

    final newRow = Map<String, dynamic>.from(rows.first);
    if (status != 'REMOVED') {
      if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt;
      if (msgTime != null) {
        newRow['last_msg_time'] = msgTime;
        if (status != 'EDITED') {
          newRow['last_event_time'] = msgTime;
        }
      }
      newRow['last_msg_text'] = messagePreviewText(msg);
      newRow['last_msg_elements'] = messagePreviewElements(msg);
      if (senderId != null) newRow['last_msg_sender'] = senderId;
      newRow['last_msg_status'] = 'sent';
    }
    if (unread != null) newRow['unread_count'] = unread;

    if (msgIdInt != null &&
        status != 'REMOVED' &&
        senderId != accountId &&
        messageMentionsUser(msg, accountId)) {
      newRow['last_mention_msg_id'] = msgIdInt;
    }

    final pinned = _extractPinnedMessage(msg);
    if (pinned != null) {
      newRow['pinned_msg_id'] = pinned.id;
      newRow['pinned_msg_text'] = pinned.text;
      newRow['pinned_msg_time'] = pinned.time;
      newRow['pinned_msg_is_preview'] = pinned.isPreview ? 1 : 0;
    }

    await AppDatabase.saveChats([newRow]);
    _bump();
  }

  ({int? id, String? text, int? time, bool isPreview})? _extractPinnedMessage(
    Map msg,
  ) {
    final attaches = msg['attaches'];
    if (attaches is! List) return null;
    for (final a in attaches.whereType<Map>()) {
      if ((a['_type'] as String?) != 'CONTROL') continue;
      final event = a['event']?.toString();
      if (event != 'pin' && event != 'unpin') continue;
      final pinned = a['pinnedMessage'];
      if (event == 'unpin' || pinned is! Map) {
        return (id: null, text: null, time: null, isPreview: false);
      }
      final rawId = pinned['id'];
      final id = rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '');
      if (id == null) return null;
      final preview = pinnedMessagePreview(pinned.cast<dynamic, dynamic>());
      return (
        id: id,
        text: preview.text,
        time: pinned['time'] as int?,
        isPreview: preview.isPreview,
      );
    }
    return null;
  }

  Future<void> _reconcileLastMessage(
    int accountId,
    int chatId,
    Map<String, dynamic> chatRow, {
    int? unread,
  }) async {
    final latest = await AppDatabase.loadMessages(
      accountId,
      chatId,
      limit: 1,
      onlyVisible: true,
    );
    final newRow = Map<String, dynamic>.from(chatRow);
    if (latest.isNotEmpty) {
      final m = latest.first;
      final rawText = m['text']?.toString();
      String? previewText = rawText;
      String? elementsJson;
      final payload = _decodePayload(m['payload']);
      if (rawText == null || rawText.isEmpty) {
        if (payload != null) previewText = messagePreviewText(payload);
      } else {
        if (payload != null) elementsJson = messagePreviewElements(payload);
      }
      newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
      newRow['last_msg_text'] = previewText ?? m['text'];
      newRow['last_msg_elements'] = elementsJson;
      newRow['last_msg_time'] = m['time'];
      newRow['last_msg_sender'] = m['sender_id'];
      newRow['last_msg_status'] = m['status'];
    } else {
      newRow['last_msg_id'] = null;
      newRow['last_msg_text'] = lastMsgPlaceholder;
      newRow['last_msg_elements'] = null;
      newRow['last_msg_sender'] = null;
      newRow['last_msg_status'] = null;
    }
    if (unread != null) newRow['unread_count'] = unread;
    await AppDatabase.saveChats([newRow]);
  }

  /// Вызывается после успешного фетча истории чата —
  /// если в превью был placeholder, заменяем его на актуальное
  /// последнее сообщение из кеша.
  Future<void> reconcileLastMessageIfPlaceholder(
    int accountId,
    int chatId,
  ) async {
    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) return;
    final chat = CachedChat.fromDbRow(rows.first);
    if (!chat.isLastMsgDeleted) return;
    await _reconcileLastMessage(accountId, chatId, rows.first);
    _bump();
  }

  Future<void> reconcileLastMessage(int accountId, int chatId) async {
    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) return;
    await _reconcileLastMessage(accountId, chatId, rows.first);
    _bump();
  }

  Future<List<String>> reconcileDeletedFromFetch(
    int accountId,
    int chatId,
    List<CachedMessage> serverMessages,
  ) async {
    if (serverMessages.isEmpty) return const [];

    final serverIds = <String>{};
    var minTime = serverMessages.first.time;
    var maxTime = serverMessages.first.time;
    for (final m in serverMessages) {
      serverIds.add(m.id);
      if (m.time < minTime) minTime = m.time;
      if (m.time > maxTime) maxTime = m.time;
    }

    final cached = await AppDatabase.loadMessages(
      accountId,
      chatId,
      limit: 300,
      onlyVisible: true,
    );

    final newlyDeleted = <String>[];
    for (final row in cached) {
      final id = row['id']?.toString();
      if (id == null || id.isEmpty || id.startsWith('temp_')) continue;
      if (serverIds.contains(id)) continue;

      final status = row['status']?.toString();
      if (status == 'pending' || status == 'sending' || status == 'error') {
        continue;
      }

      final time = row['time'] is int
          ? row['time'] as int
          : int.tryParse(row['time']?.toString() ?? '') ?? 0;
      if (time < minTime || time > maxTime) continue;

      newlyDeleted.add(id);
    }

    if (newlyDeleted.isNotEmpty) {
      await AppDatabase.markMessagesDeleted(accountId, chatId, newlyDeleted);
    }
    return newlyDeleted;
  }

  Future<void> _handleNotifMsgReactionsChanged(Packet packet) async {
    final payload = packet.payload;
    if (payload is! Map) return;
    final chatId = payload['chatId'];
    if (chatId is! int) return;
    final messageId = payload['messageId']?.toString();
    if (messageId == null || messageId.isEmpty) return;

    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) return;

    final existing = await AppDatabase.loadMessage(
      accountId,
      chatId,
      messageId,
    );
    if (existing == null) return;

    final payloadMap =
        _decodePayload(existing['payload']) ?? <String, dynamic>{};

    final counters = payload['counters'];
    final totalCount = payload['totalCount'];
    final reactionInfo = <String, dynamic>{};
    final prev = payloadMap['reactionInfo'];
    if (prev is Map && prev['yourReaction'] != null) {
      reactionInfo['yourReaction'] = prev['yourReaction'];
    }
    if (counters is List) reactionInfo['counters'] = counters;
    if (totalCount is int) reactionInfo['totalCount'] = totalCount;
    if (reactionInfo['counters'] == null ||
        (counters is List && counters.isEmpty)) {
      payloadMap.remove('reactionInfo');
    } else {
      payloadMap['reactionInfo'] = reactionInfo;
    }

    final newRow = Map<String, dynamic>.from(existing);
    newRow['payload'] = jsonEncode(payloadMap);
    await AppDatabase.saveMessages([newRow]);
    final emitted = payloadMap['reactionInfo'] as Map<String, dynamic>?;
    _messageEventsController.add(
      MessageReactionsChangedEvent(chatId, messageId, emitted),
    );
    _bump();
  }

  Future<void> _handleNotifMark(Packet packet) async {
    final payload = packet.payload;
    if (payload is! Map) return;
    final chatId = payload['chatId'];
    if (chatId is! int) return;
    final userId = payload['userId'];
    if (userId is! int) return;
    final mark = payload['mark'];
    if (mark is! int) return;
    if (payload['setAsUnread'] == true) return;

    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) return;

    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isEmpty) return;
    final cached = CachedChat.fromDbRow(rows.first);
    if (cached.participants[userId] == mark) return;
    cached.participants[userId] = mark;
    await AppDatabase.saveChats([cached.toDbRow()]);
    _bump();
  }

  final Set<int> _pendingContactUpdates = {};
  Timer? _contactFlushTimer;
  Future<void>? _contactFlushFuture;
  static const _contactFlushDelay = Duration(milliseconds: 250);

  void applyContactUpdate(int contactId) {
    _pendingContactUpdates.add(contactId);
    if (_contactFlushTimer != null) return;
    if (_contactFlushFuture != null) return;
    _contactFlushTimer = Timer(_contactFlushDelay, _kickFlush);
  }

  void _kickFlush() {
    _contactFlushTimer = null;
    if (_contactFlushFuture != null) return;
    _contactFlushFuture = _flushContactUpdates().whenComplete(() {
      _contactFlushFuture = null;
      if (_pendingContactUpdates.isNotEmpty) {
        _contactFlushTimer ??= Timer(_contactFlushDelay, _kickFlush);
      }
    });
  }

  Future<void> _flushContactUpdates() async {
    if (_pendingContactUpdates.isEmpty) return;
    final ids = _pendingContactUpdates.toList();
    _pendingContactUpdates.clear();

    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) return;

    final dialogRows = await AppDatabase.loadDialogChats(accountId);
    final byParticipant =
        <int, List<({Map<String, dynamic> row, CachedChat cached})>>{};
    for (final row in dialogRows) {
      final cached = CachedChat.fromDbRow(row);
      for (final pid in cached.participants.keys) {
        if (pid == accountId) continue;
        byParticipant.putIfAbsent(pid, () => []).add((
          row: row,
          cached: cached,
        ));
      }
    }

    final updates = <Map<String, dynamic>>[];
    for (final contactId in ids) {
      final name = ContactCache.get(contactId);
      if (name == null) continue;
      final avatar = ContactCache.getAvatar(contactId);
      final options = ContactCache.getOptions(contactId) ?? const <String>{};
      final affected = byParticipant[contactId];
      if (affected == null) continue;
      for (final entry in affected) {
        final row = entry.row;
        final cached = entry.cached;
        final sameTitle = cached.title == name;
        final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? '');
        final sameOptions =
            cached.options.length == options.length &&
            cached.options.containsAll(options);
        if (sameTitle && sameAvatar && sameOptions) continue;
        final newRow = Map<String, dynamic>.from(row);
        newRow['title'] = name;
        newRow['icon_url'] = avatar;
        newRow['options'] = options.isEmpty ? null : options.join(',');
        updates.add(newRow);
      }
    }
    if (updates.isNotEmpty) {
      await AppDatabase.saveChats(updates);
      _bump();
    }
  }

  Future<CachedChat?> cacheServerChat(
    Map<dynamic, dynamic> chat,
    int accountId, {
    Map<int, CachedChat>? preloadedExisting,
    bool inList = true,
  }) async {
    final cachedAt = DateTime.now().millisecondsSinceEpoch;
    final id = chat['id'];
    Map<int, CachedChat> existing = const {};
    Map<String, dynamic>? existingRow;
    if (preloadedExisting != null) {
      existing = preloadedExisting;
    } else if (id is int) {
      final rows = await AppDatabase.loadChat(accountId, id);
      if (rows.isNotEmpty) {
        existingRow = rows.first;
        existing = {id: CachedChat.fromDbRow(existingRow)};
      }
    }
    final parsed = parseChatRow(
      chat,
      accountId,
      accountId,
      const {},
      const {},
      const {},
      existing,
      cachedAt,
    );
    if (parsed == null) {
      logger.w('cacheServerChat: parse returned null for chat=${chat['id']}');
      return null;
    }
    final ex = existing[parsed.id];
    final listState = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1);
    final membershipUnchanged =
        existingRow == null || existingRow['in_list'] == listState;
    if (ex != null && sameChatContent(ex, parsed) && membershipUnchanged) {
      return parsed;
    }
    final row = parsed.toDbRow();
    row['in_list'] = listState;
    await AppDatabase.saveChats([row]);
    _bump();
    return parsed;
  }

  /// Парсит и кэширует чаты из payload opcode 19.
  ///
  /// Для диалогов разрезолвит имя и аватар из списка [contacts] того же
  /// ответа. На warm start контакты не приходят — используется существующий
  /// кэш.
  Future<void> syncFromLoginPayload(
    Map<dynamic, dynamic> data,
    int accountId,
    int currentUserId,
  ) async {
    try {
      final chats = data['chats'];
      if (chats is! List || chats.isEmpty) return;

      final contactsMap = buildContactsMap(data['contacts']);
      // Config contains mute setup and fav indexes: config -> chats -> id
      final configMap = data['config'] is Map ? data['config'] as Map : {};
      final chatsConfig = configMap['chats'] is Map
          ? configMap['chats'] as Map
          : {};

      // Presence for online statuses
      final presenceMap = data['presence'] is Map
          ? data['presence'] as Map
          : {};
      PresenceFetch.primeAll(presenceMap);

      await _persistChatMaps(
        chats,
        accountId,
        currentUserId,
        contactsMap: contactsMap,
        chatsConfig: chatsConfig,
        presenceMap: presenceMap,
      );
    } catch (e) {
      logger.e("Ошибка при синке: $e");
    }
  }

  Future<int> _persistChatMaps(
    List<dynamic> chats,
    int accountId,
    int currentUserId, {
    Map<int, Map<dynamic, dynamic>> contactsMap = const {},
    Map<dynamic, dynamic> chatsConfig = const {},
    Map<dynamic, dynamic> presenceMap = const {},
  }) async {
    final cachedAt = DateTime.now().millisecondsSinceEpoch;
    final existingRows = await AppDatabase.loadChats(
      accountId,
      includeHidden: true,
    );
    final existing = {
      for (final row in existingRows)
        row['id'] as int: CachedChat.fromDbRow(row),
    };

    final rows = <Map<String, dynamic>>[];
    for (final c in chats.whereType<Map>()) {
      final map = c.cast<dynamic, dynamic>();
      final parsed = parseChatRow(
        map,
        accountId,
        currentUserId,
        contactsMap,
        chatsConfig,
        presenceMap,
        existing,
        cachedAt,
      );
      if (parsed == null) continue;
      final row = parsed.toDbRow();
      row['in_list'] = map['status'] == 'HIDDEN' ? 2 : 1;
      rows.add(row);
    }

    if (rows.isNotEmpty) {
      await AppDatabase.saveChats(rows);
      _bump();
    }
    return rows.length;
  }

  Future<void> paginateChats(
    Api api,
    int accountId,
    int currentUserId,
    Map<dynamic, dynamic> loginData,
  ) async {
    if (_paginatedAccountId == accountId) return;
    _paginatedAccountId = accountId;
    try {
      var marker = loginData['chatMarker'];
      if (marker is! int || marker <= 0) return;

      const count = 50;
      var page = 0;
      while (page < 200) {
        page++;
        final resp = await api.sendRequestMap(Opcode.chatsList, {
          'marker': marker,
          'count': count,
        });
        if (resp == null) break;
        final chats = resp['chats'];
        if (chats is! List || chats.isEmpty) break;

        await _persistChatMaps(chats, accountId, currentUserId);

        final next = resp['marker'];
        if (next is! int || next == marker || chats.length < count) break;
        marker = next;
      }
      await applyFavorites(accountId);
    } catch (e) {
      _paginatedAccountId = null;
      logger.w('Пагинация чатов: $e');
    }
  }

  Future<List<CachedChat>> getChats(
    int accountId, {
    bool includeHidden = false,
  }) async {
    try {
      final rows = await AppDatabase.loadChats(
        accountId,
        includeHidden: includeHidden,
      );
      final chats = rows.map(CachedChat.fromDbRow).toList();
      return chats;
    } catch (e) {
      logger.e("Ошибка при получении чатов: $e");
      return [];
    }
  }

  Future<List<CachedChat>> getChat(int accountId, int chatId) async {
    try {
      final rows = await AppDatabase.loadChat(accountId, chatId);

      return rows.map(CachedChat.fromDbRow).toList();
    } catch (e) {
      logger.e("Ошибка при получении чата: $e");

      return [];
    }
  }

  Future<void> clearCache(int accountId) =>
      AppDatabase.clearChatsCache(accountId);

  Future<Map<String, dynamic>?> getChatInfo(Api api, int chatId) async {
    final packet = await api.sendRequest(Opcode.chatInfo, {
      'chatIds': [chatId],
    });
    if (packet.isError) return null;
    final payload = packet.payload as Map?;
    final chats = payload?['chats'] as List?;
    if (chats == null || chats.isEmpty) return null;
    return Map<String, dynamic>.from(chats.first as Map);
  }

  Future<Map<int, int>> getReadMarks(Api api, int accountId, int chatId) async {
    try {
      final info = await getChatInfo(api, chatId);
      final fresh = parseParticipants(info?['participants']);
      if (fresh.isNotEmpty) return fresh;
    } catch (e) {
      logger.w('Не удалось получить отметки прочтения для $chatId: $e');
    }
    final rows = await getChat(accountId, chatId);
    return rows.isEmpty ? const {} : rows.first.participants;
  }

  Future<dynamic> searchById(Api api, int userId) async {
    final packet = await api.sendRequest(Opcode.publicSearch, {
      'query': userId.toString(),
      'from': 0,
      'count': 10,
    });
    return packet.payload;
  }

  Future<List<MessageSearchHit>> searchMessages(
    Api api,
    String query, {
    int count = 50,
  }) async {
    final term = query.trim();
    if (term.isEmpty) return const [];
    try {
      final packet = await api.sendRequest(Opcode.chatSearch, {
        'count': count,
        'query': term,
      });
      if (packet.isError) return const [];
      return parseMessageResult(packet.payload);
    } catch (e) {
      logger.w('searchMessages failed: $e');
      return const [];
    }
  }

  Future<List<ChatSearchHit>> searchPublic(
    Api api,
    String query, {
    int count = 20,
  }) async {
    final term = query.trim();
    if (term.isEmpty) return const [];
    try {
      final packet = await api.sendRequest(Opcode.publicSearch, {
        'type': 'ALL',
        'count': count,
        'query': term,
      });
      if (packet.isError) return const [];
      return parseSearchResult(packet.payload);
    } catch (e) {
      logger.w('searchPublic failed: $e');
      return const [];
    }
  }

  Future<void> subscribeChat(
    Api api,
    int chatId, {
    bool subscribe = true,
  }) async {
    try {
      await api.sendRequest(Opcode.chatSubscribe, {
        'chatId': chatId,
        'subscribe': subscribe,
      }, silent: true);
    } catch (e) {
      logger.w('subscribeChat failed: $e');
    }
  }

  Future<({CachedChat chat, int? subscribersCount})> joinChannel(
    Api api,
    String link,
    int accountId,
  ) async {
    final packet = await api.sendRequest(Opcode.chatJoin, {
      'link': link,
    }, silent: true);
    if (!packet.isOk) {
      throw PacketError(messageFromErrorPayload(packet.payload));
    }
    final payload = packet.payload;
    final chatMap = payload is Map ? payload['chat'] : null;
    if (chatMap is! Map) {
      throw const PacketError('Не удалось подписаться');
    }
    final cached = await cacheServerChat(chatMap, accountId);
    if (cached == null) {
      throw const PacketError('Не удалось подписаться');
    }
    final count = chatMap['participantsCount'];
    return (chat: cached, subscribersCount: count is int ? count : null);
  }

  Future<bool> ensureChatCached(Api api, int accountId, int chatId) async {
    final rows = await AppDatabase.loadChat(accountId, chatId);
    if (rows.isNotEmpty) return true;
    try {
      final info = await getChatInfo(api, chatId);
      if (info == null) return false;
      await cacheServerChat(info, accountId, inList: false);
      return true;
    } catch (e) {
      logger.w('ensureChatCached failed for $chatId: $e');
      return false;
    }
  }

  Future<CachedChat?> createGroupChat(
    Api api, {
    required String title,
    required List<int> userIds,
    bool notify = true,
  }) => _createChat(
    api,
    chatType: 'CHAT',
    title: title,
    userIds: userIds,
    notify: notify,
  );

  Future<CachedChat?> createChannel(
    Api api, {
    required String title,
    List<int> userIds = const [],
    bool notify = true,
  }) => _createChat(
    api,
    chatType: 'CHANNEL',
    title: title,
    userIds: userIds,
    notify: notify,
  );

  Future<CachedChat?> _createChat(
    Api api, {
    required String chatType,
    required String title,
    required List<int> userIds,
    required bool notify,
  }) async {
    final payload = {
      'message': {
        'cid': DateTime.now().millisecondsSinceEpoch,
        'attaches': [
          {
            '_type': 'CONTROL',
            'event': 'new',
            'chatType': chatType,
            'title': title,
            'userIds': userIds,
          },
        ],
      },
      'notify': notify,
    };
    final packet = await api.sendRequest(Opcode.msgSend, payload);
    if (!packet.isOk) {
      logger.w('_createChat($chatType): server error payload=${packet.payload}');
      return null;
    }
    final data = packet.payload;
    if (data is! Map) {
      logger.w('_createChat($chatType): payload is not a Map: $data');
      return null;
    }
    final chat = data['chat'];
    if (chat is! Map) {
      logger.w('_createChat($chatType): response has no chat field: $data');
      return null;
    }
    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) {
      logger.w('_createChat($chatType): no active account id');
      return null;
    }
    return cacheServerChat(chat, accountId);
  }

  Future<String?> requestChatPhotoUploadUrl(Api api) async {
    final packet = await api.sendRequest(Opcode.photoUpload, {'count': 1});
    if (!packet.isOk) return null;
    final data = packet.payload;
    if (data is! Map) return null;
    return data['url'] as String?;
  }

  Future<bool> setChatPhoto(
    Api api, {
    required int chatId,
    required String photoToken,
  }) async {
    return api.sendRequestOk(Opcode.chatUpdate, {
      'chatId': chatId,
      'photoToken': photoToken,
    });
  }

  Future<bool> setChatOptions(
    Api api, {
    required int chatId,
    required Map<String, dynamic> options,
  }) async {
    return api.sendRequestOk(Opcode.chatUpdate, {
      'chatId': chatId,
      'options': options,
    });
  }

  Future<bool> setChatTitle(
    Api api, {
    required int chatId,
    required String title,
  }) async {
    final packet = await api.sendRequest(Opcode.chatUpdate, {
      'chatId': chatId,
      'theme': title,
    });
    if (!packet.isOk) return false;
    final accountId = await TokenStorage.getActiveAccountId();
    if (accountId == null) return true;
    await _updateChat(accountId, chatId, (chat) => chat.copyWith(title: title));
    return true;
  }

  Future<String?> setPinnedMessage(
    Api api, {
    required int chatId,
    required int? messageId,
    bool notify = true,
  }) async {
    try {
      final packet = await api.sendRequest(Opcode.chatUpdate, {
        'chatId': chatId,
        'notifyPin': notify,
        'pinMessageId': messageId ?? 0,
      });
      if (!packet.isOk) {
        return messageFromErrorPayload(packet.payload);
      }
      final data = packet.payload;
      final chat = data is Map ? data['chat'] : null;
      if (chat is Map) {
        final accountId = await TokenStorage.getActiveAccountId();
        if (accountId != null) {
          await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
        }
      }
      return null;
    } on PacketError catch (e) {
      logger.w('setPinnedMessage $chatId: ${e.message}');
      return e.message;
    } catch (e) {
      logger.w('setPinnedMessage $chatId: $e');
      return 'Не удалось изменить закрепление';
    }
  }

  Future<String?> togglePin(
    Api api, {
    required List<int> chatIds,
    required bool pin,
  }) async {
    if (chatIds.isEmpty) return null;
    try {
      final accountId = await TokenStorage.getActiveAccountId();
      if (accountId == null) return 'Нет активного аккаунта';
      final folders = await FoldersModule.loadFolders(accountId);
      final allFolder = folders.firstWhere(
        FoldersModule.isAllChatsFolder,
        orElse: () => folders.isEmpty
            ? throw StateError('Папка "Все" не найдена')
            : folders.first,
      );

      final favorites = List<int>.from(allFolder.favorites ?? const []);
      if (pin) {
        for (final id in chatIds) {
          if (!favorites.contains(id)) favorites.add(id);
        }
      } else {
        favorites.removeWhere((id) => chatIds.contains(id));
      }

      await FoldersModule.setFolderFavorites(
        api,
        accountId,
        allFolder,
        favorites,
      );

      final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds);
      final updates = <Map<String, dynamic>>[];
      for (final row in existingRows) {
        final id = row['id'] as int;
        final isFav = favorites.contains(id);
        final currentFav = row['fav_index'] as int?;
        final newFav = isFav
            ? ((currentFav ?? 0) > 0 ? currentFav : favorites.indexOf(id) + 1)
            : 0;
        if (currentFav == newFav) continue;
        final newRow = Map<String, dynamic>.from(row);
        newRow['fav_index'] = newFav;
        updates.add(newRow);
      }
      if (updates.isNotEmpty) {
        await AppDatabase.saveChats(updates);
        _bump();
      }
      return null;
    } on PacketError catch (e) {
      logger.w('togglePin: ${e.message}');
      return e.message;
    } catch (e) {
      logger.w('togglePin: $e');
      return 'Не удалось изменить закрепление';
    }
  }

  Future<void> applyFavorites(int accountId) async {
    try {
      final folders = await FoldersModule.loadFolders(accountId);
      if (folders.isEmpty) return;
      final allFolder = folders.firstWhere(
        FoldersModule.isAllChatsFolder,
        orElse: () => folders.first,
      );
      final favorites = allFolder.favorites ?? const <int>[];
      final favIndexById = <int, int>{};
      for (var i = 0; i < favorites.length; i++) {
        favIndexById[favorites[i]] = i + 1;
      }

      final rows = await AppDatabase.loadChats(accountId, includeHidden: true);
      final updates = <Map<String, dynamic>>[];
      for (final row in rows) {
        final id = row['id'] as int;
        final current = (row['fav_index'] as int?) ?? 0;
        final next = favIndexById[id] ?? 0;
        if (current == next) continue;
        final newRow = Map<String, dynamic>.from(row);
        newRow['fav_index'] = next;
        updates.add(newRow);
      }
      if (updates.isNotEmpty) {
        await AppDatabase.saveChats(updates);
        _bump();
      }
    } catch (e) {
      logger.w('applyFavorites: $e');
    }
  }

  Future<String?> setChatMute(
    Api api, {
    required int chatId,
    required int dontDisturbUntil,
  }) async {
    try {
      await api.sendRequest(Opcode.config, {
        'settings': {
          'chats': {
            chatId: {'dontDisturbUntil': dontDisturbUntil},
          },
        },
      });
      final accountId = await TokenStorage.getActiveAccountId();
      if (accountId != null) {
        await _updateChat(
          accountId,
          chatId,
          (chat) => chat.copyWith(dontDisturbUntil: dontDisturbUntil),
        );
      }
      return null;
    } on PacketError catch (e) {
      logger.w('setChatMute $chatId: ${e.message}');
      return e.message;
    } catch (e) {
      logger.w('setChatMute $chatId: $e');
      return 'Не удалось изменить уведомления';
    }
  }

  Future<String?> deleteChat(
    Api api, {
    required int chatId,
    required int lastEventTime,
    required bool forAll,
  }) async {
    try {
      await api.sendRequest(Opcode.chatDelete, {
        'chatId': chatId,
        'lastEventTime': lastEventTime,
        'forAll': forAll,
      });
      final accountId = await TokenStorage.getActiveAccountId();
      if (accountId != null) {
        await AppDatabase.deleteChat(chatId, accountId);
        _bump();
      }
      return null;
    } on PacketError catch (e) {
      logger.w('deleteChat $chatId: ${e.message}');
      return e.message;
    } catch (e) {
      logger.w('deleteChat $chatId: $e');
      return 'Не удалось удалить чат';
    }
  }

  Future<String?> clearHistory(
    Api api, {
    required int chatId,
    required int lastEventTime,
    bool forAll = false,
  }) async {
    try {
      await api.sendRequest(Opcode.chatClear, {
        'chatId': chatId,
        'lastEventTime': lastEventTime,
        'forAll': forAll,
      });
      final accountId = await TokenStorage.getActiveAccountId();
      if (accountId != null) {
        await AppDatabase.clearMessages(accountId, chatId);
        MessageSessionCache.remove(accountId, chatId);
        _historyFetched.remove(chatId);
        await reconcileLastMessage(accountId, chatId);
      }
      return null;
    } on PacketError catch (e) {
      logger.w('clearHistory $chatId: ${e.message}');
      return e.message;
    } catch (e) {
      logger.w('clearHistory $chatId: $e');
      return 'Не удалось очистить историю';
    }
  }

  Future<bool> leaveChat(Api api, {required int chatId}) async {
    try {
      await api.sendRequest(Opcode.chatLeave, {'chatId': chatId});
      final accountId = await TokenStorage.getActiveAccountId();
      if (accountId != null) {
        await AppDatabase.deleteChat(chatId, accountId);
        _bump();
      }
      return true;
    } catch (_) {
      return false;
    }
  }

  Future<bool> addMembers(
    Api api, {
    required int chatId,
    required List<int> userIds,
    bool showHistory = true,
  }) async {
    if (userIds.isEmpty) return false;
    try {
      final packet = await api.sendRequest(Opcode.chatMembersUpdate, {
        'chatId': chatId,
        'userIds': userIds,
        'showHistory': showHistory,
        'operation': 'add',
      });
      if (!packet.isOk) {
        logger.w('addMembers $chatId: ${messageFromErrorPayload(packet.payload)}');
        return false;
      }
      final data = packet.payload;
      final chat = data is Map ? data['chat'] : null;
      if (chat is Map) {
        final accountId = await TokenStorage.getActiveAccountId();
        if (accountId != null) {
          await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
        }
      }
      return true;
    } on PacketError catch (e) {
      logger.w('addMembers $chatId: ${e.message}');
      return false;
    } catch (e) {
      logger.w('addMembers $chatId: $e');
      return false;
    }
  }

  Future<ChatMembersPage?> getChatMembers(
    Api api,
    int chatId, {
    int marker = 0,
    int count = 50,
  }) async {
    try {
      final packet = await api.sendRequest(Opcode.chatMembers, {
        'type': 'MEMBER',
        'marker': marker,
        'chatId': chatId,
        'count': count,
      });
      if (!packet.isOk) return null;
      final payload = packet.payload;
      if (payload is! Map) return null;

      final entries = <ChatMemberEntry>[];
      final presenceById = <int, Map<String, dynamic>>{};
      final rawMembers = payload['members'];
      if (rawMembers is List) {
        for (final m in rawMembers.whereType<Map>()) {
          final contact = m['contact'];
          if (contact is! Map) continue;
          final id = contact['id'];
          if (id is! int) continue;

          final info = ContactInfo.fromMap(Map<String, dynamic>.from(contact));
          final name = info.displayName;
          if (name != null && name.isNotEmpty) ContactCache.put(id, name);
          final avatar = info.avatarUrl;
          if (avatar != null && avatar.isNotEmpty) {
            ContactCache.putAvatar(id, avatar);
          }
          final phone = contact['phone'];
          if (phone is int && phone > 0) ContactCache.putPhone(id, phone);
          ContactInfoFetch.putContact(id, contact.cast<dynamic, dynamic>());

          final presence = m['presence'];
          var status = 0;
          int? seen;
          if (presence is Map) {
            final p = Map<String, dynamic>.from(presence);
            presenceById[id] = p;
            status = (p['status'] as int?) ?? 0;
            final s = p['seen'];
            seen = s is int ? s : null;
          }

          entries.add(
            ChatMemberEntry(
              id: id,
              name: name,
              fullName: info.fullName,
              avatarUrl: avatar,
              seenTime: seen,
              presenceStatus: status,
              blocked: info.isDeleted,
              isContact: info.isSavedContact,
            ),
          );
        }
      }
      if (presenceById.isNotEmpty) PresenceFetch.primeAll(presenceById);

      final next = payload['marker'];
      return ChatMembersPage(
        members: entries,
        marker: next is int ? next : marker,
      );
    } on PacketError catch (e) {
      logger.w('getChatMembers $chatId: ${e.message}');
      return null;
    } catch (e) {
      logger.w('getChatMembers $chatId: $e');
      return null;
    }
  }

  Future<List<CachedChat>> refreshChats(Api api, List<int> chatIds) async {
    if (chatIds.isEmpty) return const [];
    try {
      final packet = await api.sendRequest(Opcode.chatInfo, {
        'chatIds': chatIds,
      });
      final payload = packet.payload;
      if (payload is! Map) return const [];
      final list = payload['chats'];
      if (list is! List) return const [];
      final accountId = await TokenStorage.getActiveAccountId();
      if (accountId == null) return const [];
      final existingRows = await AppDatabase.loadChatsByIds(accountId, chatIds);
      final preloadedExisting = {
        for (final row in existingRows)
          row['id'] as int: CachedChat.fromDbRow(row),
      };
      final out = <CachedChat>[];
      for (final c in list) {
        if (c is Map) {
          final cached = await cacheServerChat(
            c,
            accountId,
            preloadedExisting: preloadedExisting,
          );
          if (cached != null) out.add(cached);
        }
      }
      return out;
    } on PacketError catch (e) {
      logger.w('refreshChats: ${e.message}');
      return const [];
    } catch (e) {
      logger.w('refreshChats: $e');
      return const [];
    }
  }
}

final chats = ChatsModule._();
