import 'package:flutter/foundation.dart'; import '../../core/cache/info_cache.dart'; import '../../core/config/debug_test.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; import '../../models/contact_info.dart'; import '../api.dart'; import 'messages.dart'; class CachedContact { final int id; final int accountId; final String firstName; final String? lastName; final int phone; final int? photoId; final String? baseUrl; final String? baseRawUrl; final int updateTime; final Set options; const CachedContact({ required this.id, required this.accountId, required this.firstName, this.lastName, required this.phone, this.photoId, this.baseUrl, this.baseRawUrl, required this.updateTime, this.options = const {}, }); bool get isOfficial => options.contains('OFFICIAL'); bool get isBot => options.contains('BOT'); bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); bool get isVerified => isOfficial; factory CachedContact.fromDbRow(Map row) => CachedContact( id: row['id'] as int, accountId: row['account_id'] as int, firstName: row['first_name'] as String, lastName: row['last_name'] as String?, phone: row['phone'] as int, photoId: row['photo_id'] as int?, baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, updateTime: row['update_time'] as int, options: _decodeOptions(row['options']), ); static Set _decodeOptions(dynamic raw) { if (raw is! String || raw.isEmpty) return const {}; return raw.split(',').where((s) => s.isNotEmpty).toSet(); } } class PhoneLookupResult { final int id; final String? name; final String? avatarUrl; final int phone; const PhoneLookupResult({ required this.id, this.name, this.avatarUrl, this.phone = 0, }); } class ContactPhotos { final List urls; final int total; const ContactPhotos({required this.urls, required this.total}); static const empty = ContactPhotos(urls: [], total: 0); } enum AddContactStatus { added, notFound, error } class AddContactResult { final AddContactStatus status; final CachedContact? contact; const AddContactResult(this.status, {this.contact}); } class ContactsModule { static final ValueNotifier revision = ValueNotifier(0); static Future findByPhone( Api api, String phone, { bool silent = false, }) async { final normalized = _normalizePhone(phone); if (normalized == null) return null; final Packet packet; try { packet = await api.sendRequest(Opcode.contactInfoByPhone, { 'phone': normalized, }, silent: silent); } on PacketError { return null; } if (packet.isError) return null; final contact = (packet.payload as Map?)?['contact']; if (contact is! Map) return null; final id = contact['id']; if (id is! int) return null; primeContactCache(contact); final payloadPhone = contact['phone']; final resolvedPhone = payloadPhone is int && payloadPhone > 0 ? payloadPhone : int.tryParse(normalized.substring(1)) ?? 0; ContactCache.putPhone(id, resolvedPhone); String? name; final names = contact['names']; if (names is List) { final n = names.firstWhere((e) => e is Map, orElse: () => null); if (n is Map) { final first = (n['firstName'] as String?) ?? (n['name'] as String?) ?? ''; final last = (n['lastName'] as String?) ?? ''; final full = '$first $last'.trim(); if (full.isNotEmpty) name = full; } } return PhoneLookupResult( id: id, name: name, avatarUrl: contact['baseUrl'] as String?, phone: resolvedPhone, ); } static String? _normalizePhone(String raw) { final digits = raw.replaceAll(RegExp(r'[^\d]'), ''); if (digits.length < 5) return null; return '+$digits'; } static Future addContact( Api api, int id, String firstName, { int phone = 0, }) async { final resp = await api.sendRequest(Opcode.contactUpdate, { 'action': 'ADD', 'contactId': id, 'firstName': firstName, }); final profile = await AppDatabase.loadActiveProfile(); if (profile == null) return null; final data = resp.payload; final contact = (data is Map && data['contact'] is Map) ? (data['contact'] as Map).cast() : null; final row = contact != null ? _parseContact(contact, profile.id) : { 'id': id, 'account_id': profile.id, 'first_name': firstName, 'last_name': null, 'phone': 0, 'photo_id': null, 'base_url': null, 'base_raw_url': null, 'update_time': 0, 'options': null, }; if (row == null) return null; if (phone > 0 && ((row['phone'] as int?) ?? 0) == 0) { row['phone'] = phone; } await AppDatabase.saveContacts([row]); if (contact != null) primeContactCache(contact); revision.value++; return CachedContact.fromDbRow(row); } static Future addContactByPhone( Api api, { required String phone, required String firstName, String lastName = '', }) async { final normalized = _normalizePhone(phone); if (normalized == null) { return const AddContactResult(AddContactStatus.error); } final Packet resp; try { resp = await api.sendRequest(Opcode.contactAddByPhone, { 'phone': normalized, 'firstName': firstName, 'lastName': lastName, }, silent: true); } on PacketError catch (e) { final key = e.errorKey ?? ''; final notFound = key == 'user.not.found' || e.message.toLowerCase().contains('not found'); return AddContactResult( notFound ? AddContactStatus.notFound : AddContactStatus.error, ); } catch (_) { return const AddContactResult(AddContactStatus.error); } final data = resp.payload; final contact = (data is Map && data['contact'] is Map) ? (data['contact'] as Map).cast() : null; if (contact == null) { return const AddContactResult(AddContactStatus.error); } final profile = await AppDatabase.loadActiveProfile(); if (profile == null) { return const AddContactResult(AddContactStatus.error); } final row = _parseContact(contact, profile.id); if (row == null) { return const AddContactResult(AddContactStatus.error); } await AppDatabase.saveContacts([row]); primeContactCache(contact); revision.value++; return AddContactResult( AddContactStatus.added, contact: CachedContact.fromDbRow(row), ); } static Future updateContact( Api api, { required int contactId, required String firstName, required String lastName, }) async { final Packet resp; try { resp = await api.sendRequest(Opcode.contactUpdate, { 'contactId': contactId, 'action': 'UPDATE', 'firstName': firstName, 'lastName': lastName, }); } catch (_) { return null; } final profile = await AppDatabase.loadActiveProfile(); if (profile == null) return null; final data = resp.payload; final contact = (data is Map && data['contact'] is Map) ? (data['contact'] as Map).cast() : null; if (contact == null) return null; final row = _parseContact(contact, profile.id); if (row == null) return null; await AppDatabase.saveContacts([row]); primeContactCache(contact); ContactInfoFetch.putContact(contactId, contact); revision.value++; return CachedContact.fromDbRow(row); } static Future removeContact(Api api, int contactId) async { try { await api.sendRequest(Opcode.contactUpdate, { 'contactId': contactId, 'action': 'REMOVE', }); } catch (_) { return false; } final profile = await AppDatabase.loadActiveProfile(); if (profile != null) { await AppDatabase.deleteContact(profile.id, contactId); } ContactCache.remove(contactId); ContactInfo? info = ContactInfoFetch.peek(contactId); if (info == null) { ContactInfoFetch.invalidate(contactId); info = await ContactInfoFetch.get(contactId, forceRefresh: true); } final rawNames = info?.raw['names']; if (info != null && rawNames is List) { final stripped = rawNames .where((n) => !(n is Map && n['type'] == 'CUSTOM')) .toList(); final newRaw = Map.from(info.raw)..['names'] = stripped; ContactInfoFetch.putContact(contactId, newRaw); primeContactCache(newRaw); } else { ContactInfoFetch.invalidate(contactId); } revision.value++; return true; } static Future syncFromLoginPayload( Map data, int accountId, ) async { final contacts = data['contacts']; if (contacts is! List || contacts.isEmpty) return; final rows = >[]; for (final raw in contacts.whereType()) { final contact = raw.cast(); final row = _parseContact(contact, accountId); if (row != null) rows.add(row); primeContactCache(contact); } if (rows.isNotEmpty) { await AppDatabase.saveContacts(rows); revision.value++; } logger.i( 'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)', ); } static Future syncFromServer(Api api, int accountId) async { final map = await api.sendRequestMap(Opcode.contactsGet, { 'contactsSync': 0, }); if (map == null) return; await syncFromLoginPayload(map.cast(), accountId); } static Future fetchSelfProfile(Api api, int accountId) async { final map = await api.sendRequestMap(Opcode.contactInfo, { 'contactIds': [accountId], }); final contacts = map?['contacts']; if (contacts is! List) return null; for (final raw in contacts.whereType()) { if (raw['id'] != accountId) continue; final contact = raw.cast(); primeContactCache(contact); return ProfileData.fromServerMap(contact); } return null; } static void primeContactCache(Map contact) { final id = contact['id']; if (id is! int) return; final phone = contact['phone']; if (phone is int) ContactCache.putPhone(id, phone); final names = contact['names']; if (names is List && names.isNotEmpty) { final nameRaw = _preferredNameEntry(names); if (nameRaw != null) { final firstName = (nameRaw['firstName'] as String?) ?? ''; final lastName = nameRaw['lastName'] as String?; final fullName = (lastName != null && lastName.isNotEmpty) ? '$firstName $lastName' : firstName; if (fullName.isNotEmpty) ContactCache.put(id, fullName); } } final baseUrl = contact['baseUrl'] as String?; if (baseUrl != null && baseUrl.isNotEmpty) { ContactCache.putAvatar(id, baseUrl); } } static Future fetchPhotos( Api api, int contactId, { int from = 0, int count = 25, }) async { final map = await api.sendRequestMap(Opcode.contactPhotos, { 'contactId': contactId, 'from': from, 'count': count, }); if (map == null) return ContactPhotos.empty; final rawUrls = map['urls']; final urls = rawUrls is List ? rawUrls.whereType().toList() : []; final total = map['total'] is int ? map['total'] as int : urls.length; return ContactPhotos(urls: urls, total: total); } static Future> getContacts(int accountId) async { final rows = await AppDatabase.loadContacts(accountId); return rows.map(CachedContact.fromDbRow).toList(); } static Future getContact(int accountId, int id) async { final row = await AppDatabase.loadContact(accountId, id); return row == null ? null : CachedContact.fromDbRow(row); } static const List _debugFirstNames = [ 'Алиса', 'Борис', 'Вера', 'Глеб', 'Дарья', 'Егор', 'Жанна', 'Захар', 'Ирина', 'Кирилл', 'Лия', 'Максим', 'Нина', 'Олег', 'Полина', 'Роман', 'София', 'Тимур', 'Ульяна', 'Фёдор', 'Ханна', 'Цветана', 'Чеслав', 'Шура', ]; static const List _debugLastNames = [ 'Иванов', 'Петров', 'Сидоров', 'Кузнецов', 'Смирнов', 'Попов', 'Волков', 'Соколов', 'Морозов', 'Новиков', 'Фёдоров', 'Козлов', ]; static List debugContacts() { final count = DebugTest.contactCount; final out = []; for (var i = 0; i < count; i++) { final first = _debugFirstNames[i % _debugFirstNames.length]; final last = _debugLastNames[(i ~/ _debugFirstNames.length) % _debugLastNames.length]; out.add( CachedContact( id: 900000000 + i, accountId: DebugTest.debugAccountId, firstName: '$first ${i + 1}', lastName: last, phone: 79000000000 + i, baseUrl: 'https://i.pravatar.cc/150?u=komet_debug_$i', updateTime: 1, options: i % 6 == 0 ? const {'OFFICIAL'} : const {}, ), ); } return out; } /// Прогревает in-memory ContactCache из локальных контактов. /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. static Future primeCacheFromDb(int accountId) async { final contacts = await getContacts(accountId); for (final c in contacts) { ContactCache.putPhone(c.id, c.phone); final fullName = (c.lastName != null && c.lastName!.isNotEmpty) ? '${c.firstName} ${c.lastName}' : c.firstName; if (fullName.isNotEmpty) ContactCache.put(c.id, fullName); if (c.baseUrl != null && c.baseUrl!.isNotEmpty) { ContactCache.putAvatar(c.id, c.baseUrl); } } } static Map? _preferredNameEntry(List names) { Map? oneme; Map? any; for (final n in names) { if (n is! Map) continue; any ??= n; final type = n['type']; if (type == 'CUSTOM') return n; if (type == 'ONEME') oneme ??= n; } return oneme ?? any; } static Map? _parseContact( Map contact, int accountId, ) { final id = contact['id']; if (id is! int) return null; String firstName = ''; String? lastName; final names = contact['names']; if (names is List && names.isNotEmpty) { final nameRaw = _preferredNameEntry(names); if (nameRaw == null) return null; firstName = (nameRaw['firstName'] as String?) ?? ''; lastName = nameRaw['lastName'] as String?; } final optionsRaw = contact['options']; String? optionsStr; if (optionsRaw is List) { optionsStr = optionsRaw.whereType().join(','); } return { 'id': id, 'account_id': accountId, 'first_name': firstName, 'last_name': lastName, 'phone': (contact['phone'] as int?) ?? 0, 'photo_id': contact['photoId'] as int?, 'base_url': contact['baseUrl'] as String?, 'base_raw_url': contact['baseRawUrl'] as String?, 'update_time': (contact['updateTime'] as int?) ?? 0, 'options': optionsStr, }; } }